While verifying how binding invalidates a view (indirectly), I find an unexpected behavior.
-
If the view hierarchy is
list view -> detail view
it works fine (as expected) to press a button in the detail view to delete the item.
-
However, if the view hierarchy is
list view -> detail view -> another detail view (containing the same item)
it crashes when I press a button in the top-most detail view to delete the item. The crash occurs in the first detail view (the underlying one), because its body gets called.
To put it in another way, the behavior is:
-
If the detail view is the top-most view in the navigation stack, its body doesn't get called.
-
Otherwise, its body gets called.
I can't think out any reason for this behavior. My debugging showed below are what happened before the crash:
- I pressed a button in top-most detail view to delete the item.
- The
ListView
's body got called (as a result ofContentView
body got called). It created only the detail view for the left item. - Then the first
DetailView
's body get called. This is what caused the crash. I can't think out why this occurred, because it certainly didn't occur for the top-most detail view.
Below is the code. Note the ListView
and DetailView
contains only binding and regular properties (they don't contain observable object or environment object, which I'm aware complicate the view invalidation behavior).
import SwiftUI
struct Foo: Identifiable {
var id: Int
var value: Int
}
// Note that I use forced unwrapping in data model's APIs. This is intentional. The rationale: the caller of data model API should make sure it passes a valid id.
extension Array where Element == Foo {
func get(_ id: Int) -> Foo {
return first(where: { $0.id == id })!
}
mutating func remove(_ id: Int) {
let index = firstIndex(where: { $0.id == id })!
remove(at: index)
}
}
class DataModel: ObservableObject {
@Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
}
struct ListView: View {
@Binding var foos: [Foo]
var body: some View {
NavigationView {
List {
ForEach(foos) { foo in
NavigationLink {
DetailView(foos: $foos, fooID: foo.id, label: "First detail view")
} label: {
Text("\(foo.value)")
}
}
}
}
}
}
struct DetailView: View {
@Binding var foos: [Foo]
var fooID: Int
var label: String
var body: some View {
// The two print() calls are for debugging only.
print(Self._printChanges())
print(label)
print(fooID)
return VStack {
Text(label)
Divider()
Text("Value: \(foos.get(fooID).value)")
NavigationLink {
DetailView(foos: $foos, fooID: fooID, label: "Another detail view")
} label: {
Text("Create another detail view")
}
Button("Delete It") {
foos.remove(fooID)
}
}
}
}
struct ContentView: View {
@StateObject var dataModel = DataModel()
var body: some View {
ListView(foos: $dataModel.foos)
}
}
Test 1: Start the app, click on an item in the list view to go to the detail view, then click on "Delete It" button. This works fine.
The view hierarchy: list view -> detail view
Test 2: Start the app, click on an item in the list view to go to the detail view, then click on "Create another detail view" to go to another detail view. Then click on "Delete It" button. The crashes the first detail view.
The view hierarchy: list view -> detail view -> another detail view
Could it be just another bug of @Binding
? Is there any robust way to work around the issue?