(My apologies that this is a SwiftUI question. I asked it on SO and Apple developer forum but didn't get a satisfying answer. I would appreciate it if anyone can share how you think about it.)
One important rule in SwiftUI is the single source of the truth. Given that, it's natural for one to assume that all the data accessed through the property wrappers provided by SwiftUI are consistent. But that's not true, as demonstrated in the example below.
The example app has two views: a list view and a detail view. Clicking an item in the list view goes to the detail view. The detail view contains a "Delete It" button. Clicking on the button crashes the detail view because the data in @EnvironmentObject
and the data in @Binding
are inconsistent.
import SwiftUI
struct Foo: Identifiable {
var id: Int
var value: Int
}
// Note that I use forced unwrapping in data model's APIs. The rationale: the caller of data model API should make sure it passes a valid id.
class DataModel: ObservableObject {
@Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
func get(_ id: Int) -> Foo {
return foos.first(where: { $0.id == id })!
}
func remove(_ id: Int) {
let index = foos.firstIndex(where: { $0.id == id })!
foos.remove(at: index)
}
}
struct ListView: View {
@StateObject var dataModel = DataModel()
var body: some View {
NavigationView {
List {
ForEach($dataModel.foos) { $foo in
NavigationLink {
DetailView(fooID: $foo.id)
} label: {
Text("\(foo.value)")
}
}
}
}
.environmentObject(dataModel)
}
}
struct DetailView: View {
@EnvironmentObject var dataModel: DataModel
// Note: I know I can pass the entire Foo's value to the detail view in this simple example. I pass Foo's id just to demonstrate the issue.
@Binding var fooID: Int
var body: some View {
// The two print() calls are for debugging only.
print(Self._printChanges())
print(fooID)
return VStack {
Text("\(dataModel.get(fooID).value)")
Button("Delete It") {
dataModel.remove(fooID)
}
}
}
}
struct ContentView: View {
var body: some View {
ListView()
}
}
Below is my analysis of the root cause of the crash:
-
When the "Delete It" button gets clicked, it removes the item from data model.
-
Since the detail view accesses data model through
@EnvironmentObject
, the data model change triggers call of detail view's body. -
It turns out that the
fooID
binding in the detail view doesn't get updated at the time, and henceDataModel.get(id:)
call crashes the app.
I didn't expect the crash, because I thought the fooID
binding value in the detail view would get updated before the detail view's body gets called. In my opinion the current behavior is a severe bug because it breaks the data consistency assumption. But when I google about this I find nothing.
I considered a few approaches to solve or work around the issue.
- Don't use
@EnvironmentObject
This is the approach used in Apple's official tutorial, which passes binding all the way from list view to detail view and to edit view and uses no environment object. The approach is simple and elegant.
But unfortunately, in my opinion, this approach only works for simple data model. For example, if the item to be displayed in the detail view contains not only its own value but also the id of a related item. To show the basic information of that related item, we need to access data model. In general, I don't think it's always feasible to prepare all data needed by the detail view ahead in practical applications with complex data models..
- Validate param before calling data model API
In this approach, I accept the fact that binding value and the data model in environment object can be inconsistent so I validate binding value before passing it to data model API. However, if the binding value needs to be validated, there is no point in using it. Why not just use the regular property instead? So I end up with code like the following:
struct DetailView: View {
@EnvironmentObject var dataModel: DataModel
// No need to use binding.
var fooID: Int
var body: some View {
// Note I modified DataModel.get() to return optional value.
if let foo = dataModel.get(fooID) {
VStack {
Text("\(foo.value)")
Button("Delete It") {
dataModel.remove(foo.id)
}
}
}
}
}
(In my App's code, I introduced view modifier and view wrapper to encapsulate the if statement and to also validate states in the view. But the basic idea is the above.)
The approach works well. My only concern is that it's very different from the approach used in Apple's demo apps (I'm aware there are many different ways to pass data between views though). Also, I'd hope to use Apple recommended approach (if it's possible) because it's simple and elegant. Since this is a common scenario, I can hardly believe it's just me running into it. I wonder how the others understand the issue and address it? Thanks.