swiftuiswiftui-navigationlinkswiftui-navigationstack

View doesn't update if Source Array is updated


Imagine you have a struct like

struct TheObject: Identifiable, Hashable {
    var id: String
    var title: String
}

Now you store an array of that type inside a ViewModel

class Model: ObservableObject {
    @Published var objects = [TheObject(id: "id", title: "title")]
}

You create a NavigationLink for each item of objects. Those NavigationLinks feature a button, which replaces the objects array with the same object (by id) but a different property

 @StateObject var model = Model()

 NavigationStack {
     List(model.objects) { object in
         NavigationLink("Text", value: object)
     }
     .navigationDestination(for: TheObject.self) { object in
         Text(object.title)
             .onTapGesture {
                 model.objects = [TheObject(id: "id", title: "title changed")]
             }
     }
 }

What did I expect to happen?
I expected my opened NavigationLink to be updated with the new title.
What did I get instead?
The view did not change. Instead I have to pop the view and reopen it to reveal the changes.

How can I get my expected behavior to work and why does it currently behave like that?


Solution

  • When you use Text(object.title) the passed in object is a let and is not observed for changes by the view, so it will not change when you change the model in the onTapGesture.

    But the view will change if you use the model (that is observed for changes), as shown in the example code.

    struct TheObject: Identifiable, Hashable {
        var id: String
        var title: String
    }
    
    class Model: ObservableObject {
        @Published var objects = [TheObject(id: "id", title: "title")]
    }
    
    struct ContentView: View {
        @StateObject private var model = Model()
        
        var body: some View {
            NavigationStack {
                List(model.objects) { object in
                    NavigationLink(object.title, value: object) // <--- for testing
                }
                .navigationDestination(for: TheObject.self) { object in
                    if let obj = model.objects.first(where: {$0.id == object.id}) {
                        Text(obj.title) // <--- here
                            .onTapGesture {
                                model.objects = [TheObject(id: "id", title: "title changed")]
                            }
                    }
    
                // alternative
    //                if let ndx = model.objects.firstIndex(where: {$0.id == object.id}) {
    //                    Text(model.objects[ndx].title)
    //                        .onTapGesture {
    //                            model.objects[ndx].title = "title changed"
    //                        }
    //                }
    
    
                }
            }
        }
    }
    

    EDIT-1:

    An alternative to achieve what you want, is to use Observable, the more robust replacement of ObservableObject class.

    For example:

    @Observable class TheObject: Identifiable, Hashable {
        
        static func == (lhs: TheObject, rhs: TheObject) -> Bool {
            lhs.id == rhs.id
        }
        
        func hash(into hasher: inout Hasher) {
              hasher.combine(id)
              hasher.combine(title)
          }
        
        var id: String
        var title: String
        
        init(id: String, title: String) {
            self.id = id
            self.title = title
        }
    }
    
    @Observable class Model {
        var objects = [TheObject(id: "id", title: "title")]
    }
    
    struct ContentView: View {
        @State private var model = Model()
        
        var body: some View {
            NavigationStack {
                List(model.objects) { object in
                    NavigationLink(object.title, value: object)
                }
                .navigationDestination(for: TheObject.self) { object in
                    Text(object.title)
                        .onTapGesture {
                            object.title = "title changed"
                        }
                }
            }
        }
    }