Hi, please see the example code below. The 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 app.
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'sbody
. -
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.
To put it in a more general way, the nature of the issue is that SwiftUI may call a view's body
with a mix of up-to-date data model and stale binding value, which potentially can crash the app (unless we ignore invalid id in data model API code, but I don't think that's good idea because in my opinion this is an architecture issue that should be resolved on the caller side in the first place).
I have two questions:
-
Is this behavior (a view's binding value doesn't get updated when the view's body get called if the view accesses data model through
@EnvironmentObject
) by design, or is it just a limitation in the current implementation? -
In practical apps an item A may contain its own value, as well as id of another item. As a result, to show the item A in detail view, we need to access data model to get B's value by its id. The typical way to access data model is by using
@EnvironmentObject
. But this issue makes it infeasible to do that. If so, what's the alternative approach?
Thanks for any suggestions.
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 in this simple example I can pass the entire Foo's value to the detail view and the issue would be gone. 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()
}
}