rayx
rayx

Reputation: 1680

SwiftUI 4: navigationDestination()'s destination view isn't updated when state changes

While experiments with the new NavigationStack in SwiftUI 4, I find that when state changes, the destination view returned by navigationDestination() doesn't get updated. See code below.

struct ContentView: View {
    @State var data: [Int: String] = [
        1: "One",
        2: "Two",
        3: "Three",
        4: "Four"
    ]

    var body: some View {
        NavigationStack {
            List {
                ForEach(Array(data.keys).sorted(), id: \.self) { key in
                    NavigationLink("\(key)", value: key)
                }
            }
            .navigationDestination(for: Int.self) { key in
                if let value = data[key] {
                    VStack {
                        Text("This is \(value)").padding()
                        Button("Modify It") {
                            data[key] = "X"
                        }
                    }
                }
            }
        }
    }
}

Steps to reproduce the issue:

  1. Run the code and click on the first item in the list. That would bring you to the detail view of that item.

  2. The detail view shows the value of the item. It also has a button to modify the value. Click on that button. You'll observe that the value in the detail view doesn't change.

I debugged the issue by setting breakpoints at different place. My observations:

Does anyone know if this is a bug or expected behavior? If it's not a bug, how can I program to get the value in detail view updated?

BTW, if I go back to root view and click on the first item to go to its detail view again, the closure passed to navigationDestination() get executed and the detail view shows the modified value correctly.

Upvotes: 8

Views: 5601

Answers (3)

malhal
malhal

Reputation: 30569

The below works for me, the pushed detail screen continues to be updated when shown, the trick is there is a dependency set up between the navigationDestination closure and the state, e.g.

struct Counter: Identifiable {
    let id = UUID()
    var value: Int = 0
}

struct ContentView: View {
    @State var counters: [Counter] = [.init()]
    
    func counter(for id: Counter.ID) -> Counter {
        counters.first { $0.id == id }!
    }

    var body: some View {
        NavigationStack {
            List(counters) { counter in
                NavigationLink(value: counter.id) {
                    Text(counter.value, format: .number)
                }
            }
            .navigationDestination(for: Counter.ID.self) { id in
                // since this read counters, it gets called again when the counters are changed by the task below even if the destination is already presented.
                let counter = counter(for: id)
                Text(counter.value, format: .number)
            }
        }   
        .task {
            // simulate a change to the data
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                counters[0].value += 1
            }
        }
    }
}

However, since we now need to re-lookup the current value from the counters array, that signals that we should change the Counter from a struct to a class so we can use the object reference as the identity and no longer need to look it up in an array. So the navigation link value can be the pointer to the counter object and if we make counter be @Observable then its value can be monitored for changes by the destination, e.g.

@Observable
class Counter2: Identifiable, Hashable, Equatable {
    var value: Int = 0
    
    static func == (lhs: Counter2, rhs: Counter2) -> Bool {
        lhs === rhs
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(self))
    }
}

struct CounterView: View {
    @State var counters: [Counter] = [] // since Counter is now a class we can no longer init one in the default version of the array, that would be a memory leak. So it is moved to `.task`.

    var body: some View {
        NavigationStack {
            List(counters) { counter in
                NavigationLink(value: counter) {
                    Text(counter.value, format: .number)
                }
            }
            .navigationDestination(for: Counter.self) { counter in
                Text(counter.value, format: .number)
            }
        }
        .task {
            let counter = Counter()
            counters = [counter]
            // simulate a change to the data
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                counter.value += 1
            }
        }
    }
}

This is also more efficient because now only the navigationDestination closure is called when counter.value changes, not the whole body as in the first example.

Upvotes: -1

rayx
rayx

Reputation: 1680

@NoeOnJupiter's solution and @Asperi's comment are very helpful. But as you see in my comments above, there were a few details I wasn't sure about. Below is a summary of my final understanding, which hopefully clarifies the confusion.

  1. navigationDestination() takes a closure parameter. That closure captures an immutable copy of self.

    BTW, SwiftUI takes advantage of property wrapper to make it possible to "modify" an immutable value, but we won't discuss the details here.

  2. Take my above code as an example, due to the use of @State wrapper, different versions of ContentView (that is, the self captured in the closure) share the same data value.

    The key point here is I think the closure actually has access to the up-to-date data value.

  3. When an user clicks on the "Modify it" button, the data state changes, which causes body re-evaluted. Since navigationDestination() is a function in body, it get called too. But a modifier function is just shortcut to modifier(SomeModifier()). The actual work of a Modifier is in its body. Just because a modifier function is called doesn't necessarilly means the corresponding Modifier's body gets called. The latter is a mystery (an implementation detail that Apple don't disclose and is hard to guess). See this post for example (the author is a high reputation user in Apple Developer Forum):

    In my opinion, it definitely is a bug, but not sure if Apple will fix it soon.

    One workaround, pass a Binding instead of a value of @State variables.

    BTW, I have a hypothesis on this. Maybe this is based on a similar approach as how SwiftUI determines if it recalls a child view's body? My guess is that it might be a design, instead of a bug. For some reason (performance?) the SwiftUI team decided to cache the view returned by navigationDestination() until the NavigationStack is re-constructed. As a user I find this behavior is confusing, but it's not the only example of the inconsistent behaviors in SwiftUI.

So, unlike what I had thought, this is not an issue with closure, but one with how modifier works. Fortunately there is a well known and robust workaround, as suggested by @NoeOnJupiter and @Asperi.


Update: an alternative solution is to use EnvironmentObject to cause the placeholder view's body get re-called whenever data model changes. I ended up using this approach and it's reliable. The binding approach worked in my simple experiments but didn't work in my app (the placeholder view's body didn't get re-called when data model changed. I spent more than one day on this but unfortunately I can't find any way to debug it when binding stopped working mysteriously).

Upvotes: 4

RelativeJoe
RelativeJoe

Reputation: 5084

The button is correctly changing the value. By default navigationDestination does't create a Binding relation between the parent & child making the passed values immutable.

So you should create a separate struct for the child in order to achieve Bindable behavior:

struct ContentView: View {
    @State var data: [Int: String] = [
        1: "One",
        2: "Two",
        3: "Three",
        4: "Four"
    ]

    var body: some View {
        NavigationStack {
            List {
                ForEach(Array(data.keys).sorted(), id: \.self) { key in
                    NavigationLink("\(key)", value: key)
                }
            }
            .navigationDestination(for: Int.self) { key in
                SubContentView(key: key, data: $data)
            }
        }
    }
}

struct SubContentView: View {
    let key: Int
    @Binding var data: [Int: String]
    var body: some View {
        if let value = data[key] {
            VStack {
                Text("This is \(value)").padding()
                Button("Modify It") {
                    data[key] = "X"
                }
            }
        }
    }
}

Upvotes: 2

Related Questions