5

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:

  • When I clicked the button, the code in List get executed. That's as expected.

  • But the closure passed to navigationDestination() doesn't get executed, which explains why the detail view doesn't get updated.

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.

rayx
  • 1,329
  • 10
  • 23
  • 2
    This is expected behavior, the concept is the same as for sheet(item, alerts, etc. You have different context inside closure (outside view hierarchy), so external state does not refresh it. Use standalone view and binding instead. – Asperi Jul 12 '22 at 11:50
  • @Asperi Your explanation are valuable. By "have different context inside closure", do you mean the closure captures the `data` implicitly and hence doesn't see the up-to-date `data` value? That makes sense to me. I'm astonished how I survived without knowing this subtle issue in SwiftUI programming. I believe your suggestion is as same as the code given by @NoeOnJupiter. However, I'm not sure if his explanation is correct. He said " `navigationDestination` only gets called when activating a `NavigationLink` & not on `State` change". I don't think that's true. – rayx Jul 12 '22 at 12:12
  • In my understanding, when state changes, `navigationDestination()` is also called (because body gets re-evaluted). It's very likely it also recalls the closure. The closure returns a view containg stale data just because the closure captures the state's old value. Do you think if my understanding is correct? Thanks. – rayx Jul 12 '22 at 12:14
  • There is something wrong in my hypothesis above. In the hypothesis, I assumed that closure get recalled but returned a view containing out-of-date because the `data` value it captured is out-of-date. However, as I described in my original question, I set a breakpoint in the closure but it wasn't hit when I press the "Modify It" button. So the closure didn't get recalled. Hmm...it seems a mystery to me how a view modifier determines to call its closure parameter or not. – rayx Jul 12 '22 at 12:49
  • Just realized that I made another mistake. I said that the closure captures `data`. That's wrong. I believe what is actually captured is an immutable copy of `self` (the value of the entire struct). That self is later modified by the "Modify It" button action closure. But, if `State` wrapper store the actual value in the same position in the memory, I think both the old `self` and new `self` should share the same `data` value, and hence no such an issue at all. So it seems that the `data`'s actual position in memory keeps changing? This is a SwiftUI implementation detail that I don't know. – rayx Jul 12 '22 at 13:02
  • ForEach with id: \.self is usually always wrong, not sure where people are learning that from – malhal Jul 12 '22 at 15:20
  • @malhal Did you mean using an ad-hoc value (it's an `Int` value in this case) as id is not safe? I agree but it would be verbose to introduce a dedicated property (e.g. of `UUID` type) for id in this simple example. – rayx Jul 13 '22 at 01:44
  • Check the ForEach View documentation – malhal Jul 13 '22 at 08:21

2 Answers2

3

@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).

rayx
  • 1,329
  • 10
  • 23
  • Can you post an example of your actual final "fix" for this? I can't figure out what you mean by `an alternative solution is to use EnvironmentObject to cause the placeholder view's body get re-called`. Thanks – Mihai Fratu Feb 26 '23 at 09:58
  • 1
    It's similar to Timmy's code. The only difference is that he uses `Binding` in `SubContentView` but I use `EnvironmentObject`. – rayx Feb 27 '23 at 04:02
1

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"
                }
            }
        }
    }
}
Timmy
  • 4,098
  • 2
  • 14
  • 34
  • This is a great solution. For some reason, when we pass a value that is an element of a collection through Navigation Components, it just does not work at all unless it's a single element like Integer/String or whole like Array/Dictionary. – Steven-Carrot Jul 12 '22 at 11:58
  • 1
    Thanks for the solution. It works. However, I'm not sure if your explanation that `navigationDestination()` doesn't get called when state change is correct. See more in my question to @Asperi. Feel free to add your comments. Thanks. – rayx Jul 12 '22 at 12:20
  • 1
    @tail That some reason is a feature of closure in Swift. See my comments above. I actually know it well, but wasn't aware of this specific use case in SwiftUI. The solution above works because it capture the binding, instead of immutable (and stale) value. – rayx Jul 12 '22 at 12:24
  • By doesn't get called when `State` changes I meant that the `View` doesn't create any type of `Binding` with its parent. Excuse my lack of terminology. – Timmy Jul 12 '22 at 12:28
  • 1
    What you said is correct about a `View`. But I think it's a bit irrelevant. What we talk about here is a view modifier, `navigationDestination()`, which is a function in `body`. Since `body` gets re-evaluted, the `navigationDestination()` gets called also. That's how I understand it. – rayx Jul 12 '22 at 12:34
  • 1
    Hi @NoeOnJupiter I later realized that what you said (`navigationDestination()` doesn't get called when state change) was correct. It fit with my debugging result. It's just that why it was so is unclear. I googled and find no one knows this for sure. It's an implementation detail that Apple don't disclose. I created an answer to summary my understanding. Sorry for the confusion and thanks! – rayx Jul 12 '22 at 15:08