Home > OS >  The body of view to be destroyed gets called (but it shouldn't)
The body of view to be destroyed gets called (but it shouldn't)

Time:02-11

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 of ContentView 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?

CodePudding user response:

You need to use your data model rather than performing procedural code in your views. Also, don't pass items by id; Just pass the item.

Because you use the id of the Foo instead of the Foo itself, and you have a force unwrap in your get function, you get a crash.

If you refactor to use your model and not use ids it works as you want.

You don't really need your array extension. Specialised code as an extension to a generic object doesn't look right to me.

The delete code is so simple you can just handle it in your model, and do so safely with conditional unwrapping.

class DataModel: ObservableObject {
    @Published var foos: [Foo] = [Foo(id: 1, value: 1), Foo(id: 2, value: 2)]
    
    func delete(foo: Foo) {
       if let index = firstIndex(where: { $0.id == id }) {
           self.foos.remove(at: index)
       }
    }
}

struct ListView: View {
    @ObservedObject var model: DataModel

    var body: some View {
        NavigationView {
            List {
                ForEach(model.foos) { foo in
                    NavigationLink {
                        DetailView(model: model, foo: foo, label: "First detail view")
                    } label: {
                        Text("\(foo.value)")
                    }
                }
            }
        }
    }
}

struct DetailView: View {
    @ObservedObject var model: DataModel
    var foo: Foo
    var label: String

    var body: some View {
        // The two print() calls are for debugging only.
        print(Self._printChanges())
        print(label)
        print(foo.id)

        return VStack {
            Text(label)
            Divider()
            Text("Value: \(foo.value)")
            NavigationLink {
                DetailView(model: model, foo: foo, label: "Another detail view")
            } label: {
                Text("Create another detail view")
            }
            Button("Delete It") {
                model.delete(foo:foo)
            }
        }
    }
}

CodePudding user response:

I think this is very much like Paul's approach. I just kept the Array extension with the force unwrap as in OP.

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), Foo(id: 3, value: 3)]
}


struct ListView: View {
    @EnvironmentObject var dataModel: DataModel

    var body: some View {
        NavigationView {
            List {
                ForEach(dataModel.foos) { foo in
                    NavigationLink {
                        DetailView(foo: foo, label: "First detail view")
                    } label: {
                        Text("\(foo.value)")
                    }
                }
            }
        }
    }
}


struct DetailView: View {
    @EnvironmentObject var dataModel: DataModel

    var foo: Foo
    var label: String
    
    var body: some View {
        // The two print() calls are for debugging only.
        print(Self._printChanges())
        print(label)
        print(foo.id)
        
        return VStack {
            Text(label)
            Divider()
            Text("Value: \(foo.value)")
            NavigationLink {
                DetailView(foo: foo, label: "Yet Another detail view")
            } label: {
                Text("Create another detail view")
            }
            Button("Delete It") {
                dataModel.foos.remove(foo.id)
            }
        }
    }
}


struct ContentView: View {
    @StateObject var dataModel = DataModel()
    
    var body: some View {
        ListView()
            .environmentObject(dataModel)
    }
}

CodePudding user response:

Here is a working version. It's best to pass the model around so you can use array subscripting to mutate.

I also changed your id to UUID because that's what I'm used to and changed some vars that should be lets.

import SwiftUI

struct Foo: Identifiable {
    //var id: Int
    let id = UUID()
    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)]
    @Published var foos: [Foo] = [Foo(value: 1), Foo(value: 2)]
    
    func foo(id: UUID) -> Foo? {
        foos.first(where: { $0.id == id })
    }
}

struct ListView: View {
    //@Binding var foos: [Foo]
    @StateObject var dataModel = DataModel()

    var body: some View {
        NavigationView {
            List {
                //ForEach(foos) { foo in
                ForEach(dataModel.foos) { foo in
                    NavigationLink {
                        //DetailView(foos: $foos, fooID: foo.id, label: "First detail view")
                        DetailView(dataModel: dataModel, foo: foo, label: "First detail view")
                    } label: {
                        Text("\(foo.value)")
                    }
                }
            }
        }
    }
}

struct DetailView: View {
    //@Binding var foos: [Foo]
    @ObservedObject var dataModel: DataModel
    //var fooID: Int
    let foo: Foo
    let label: String

    var body: some View {
        // The two print() calls are for debugging only.
        print(Self._printChanges())
        print(label)
        //print(fooID)
        print(foo.id)

        return VStack {
            Text(label)
            Divider()
            //Text("Value: \(foos.get(fooID).value)")
            if let foo = dataModel.foo(id:foo.id) {
                Text("Value: \(foo.value) ")
            }
            NavigationLink {
                DetailView(dataModel: dataModel, foo: foo, label: "Another detail view")
            } label: {
                Text("Create another detail view")
            }
            Button("Delete It") {
                //foos.remove(fooID)
                if let index = dataModel.foos.firstIndex(where: { $0.id == foo.id } ) {
                    dataModel.foos.remove(at: index)
                }
            }
        }
    }
}

struct ContentView: View {
    // no need for @ here because body doesn't need to update when model changes
    //@StateObject var dataModel = DataModel()

    var body: some View {
        //ListView(foos: $dataModel.foos)
        ListView()
    }
}

CodePudding user response:

This is a version that uses Paul's approach but still uses binding. Note both versions don't really "solve" the issue (the behavior I described in my original question still exists) but instead "avoid" the crash by not accessing data model when rendering the view hierarchy in the body. I think this is a key point to use a framework successfully - don't fight it.

I was aware that it was a SwiftUI programming style to pass value, instead of id. But I didn't understand how that approach could scale. Paul's code helps me to understand one thing I didn't realize - it's OK to access data model in intents (e.g., Button's action), what should be avoided is to access data model in view rendering code!

BTW, I prefer to using binding because it has more predictable behavior in view invalidating. See my question and answer at https://developer.apple.com/forums/thread/699855.

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, foo: foo, label: "First detail view")
                    } label: {
                        Text("\(foo.value)")
                    }
                }
            }
        }
    }
}

struct DetailView: View {
    @Binding var foos: [Foo]
    var foo: Foo
    var label: String

    var body: some View {
        // The two print() calls are for debugging only.
        print(Self._printChanges())
        print(label)
        print(foo)

        return VStack {
            Text(label)
            Divider()
            Text("Value: \(foo.value)")
            NavigationLink {
                DetailView(foos: $foos, foo: foo, label: "Another detail view")
            } label: {
                Text("Create another detail view")
            }
            Button("Delete It") {
                foos.remove(foo.id)
            }
        }
    }
}

struct ContentView: View {
    @StateObject var dataModel = DataModel()

    var body: some View {
        ListView(foos: $dataModel.foos)
    }
}
  • Related