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() } }