swiftswiftuiios16swiftui-stateswiftui-navigationstack

Found a strange behaviour of @State when combined to the new Navigation Stack - Is it a bug or am I doing it wrong?


I have transitioned my swiftui app to the new NavigationStack programmatically managed using NavigationStack(path: $visibilityStack). While doing so, I found an unexpected behaviour of @State that makes me think the view is not dismissed correctly.

In fact, when I am replacing the view with another one in the stack, the @State variable is keeping its current value instead of being initialised, as it should be when presenting a new view.

Is it a bug? Is it a misconception (mine or someone else :-))? Your thoughts are welcome. As it is, the only workaround I see is to maintain a state in another object and synchronise...

I have created a mini-project. To reproduce, click on the NavigationLink, then click on the 'show other fruits' button to change the @State in the current View, then click a fruit button to change the view. The new view appears with the previous state (showMoreText is true, although it is declared as false during init). While doing more tests, it also appears that .onAppear is not called either. When using the old style NavigationView and isPresented, views were correctly initialised.

Full code here (except App which is the basic one), which should have been a good tutorial.

EDIT per Yrb answer: The data is handled in the fruitList of Model Fruit to keep our ViewController clean.

The controller FruitViewController is responsible to call the new views:

class Fruit: Hashable, Identifiable {
    // to conform to Identifiable
    var id: String
    
    init(name: String) {
        self.id = name
    }
    
    // to conform to Hashable which inherit from Equatable
    static func == (lhs: Fruit, rhs: Fruit) -> Bool {
        return (lhs.id == rhs.id)
    }
    
    // to conform to Hashable
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}
let fruitList = [Fruit(name: "Banana"), Fruit(name: "Strawberry"), Fruit(name: "Pineapple")]

class FruitViewController: ObservableObject {
    @Published var visibilityStack : [Fruit] = []

    // the functions that programatically call a 'new' view
    func openView(fruit: Fruit) {
        visibilityStack.removeAll()
        visibilityStack.append(fruit)
//        visibilityStack[0] = fruit    // has the same effect
    }
    
    // another one giving an example of what could be a deep link
    func bananaAndStrawberry() {
        visibilityStack.append(fruitList[0])
        visibilityStack.append(fruitList[1])
    }
}

The main ContentView which provides the NavigatoinStack:

struct ContentView: View {
    @StateObject private var fruitViewController = FruitViewController()
    
    var body: some View {
        NavigationStack(path: $fruitViewController.visibilityStack) {
            VStack {
                Button("Pile Banana and Strawberry", action: bananaAndStrawberry)
                    .padding(40)
                List(fruitList) {
                    fruit in NavigationLink(value: fruit) {
                        Text(fruit.id)
                    }
                }
            }
            .navigationDestination(for: Fruit.self) {
                    fruit in FruitView(fruitViewController: fruitViewController, fruit: fruit)
            }
        }
    }
    
    func bananaAndStrawberry() {
        fruitViewController.bananaAndStrawberry()
    }
}

The subview FruitView where the @State variable should be initialised:

struct FruitView: View {
    // the state that will change and not be initialised
    @State private var showMoreText = false
    @ObservedObject var fruitViewController: FruitViewController
    var fruit: Fruit
    
    var body: some View {
        Text("Selected fruit: " + fruit.id)
        if (showMoreText) {
            Text("The text should disappear when moving to another fruit")
            HStack(spacing: 10) {
                ForEach(fruitList) {
                    aFruit in Button(aFruit.id) {
                        fruitViewController.openView(fruit: aFruit)
                    }
                }
            }
        } else {
            Button("Show other fruits", action: showButtons)
        }
    }
    
    // let's change the state
    func showButtons() {
        showMoreText = true
    }
}

ADDENDUM after Yrb answer:

I have done another exercise, to maybe better explain my point. Add 3 views to the stack array with visibilityStack.append . Let's call the initial state: state 0. It will create a stack like this:

_View 1 - state 0_
_View 2 - state 0_
_View 3 - State 0_ (the one shown on the screen)

Let's now modify the state of our View 3 to obtain:

_View 1 - state 0_
_View 2 - state 0_ 
_View 3 - State 1_ (the one shown on the screen)

Can you tell me what is happening when you remove View 2 using visibilityStack.remove(at: 1)?

The answer is: you will obtain the following stack:

_View 1 - state 0_
_View 3 - State 0_ (the one shown on the screen)

So the View 2 is not destroyed. The last View in the stack is the one that is destroyed.

To Yrb point, it seems like if NavigationStack was a mixed approach between the ability to deal with the views, but with a kind of Model in mind.


Solution

  • Edited to add: smallest amount of code that will force the navigation stack to see views of the same type as brand new instead of caching them is to add .id(some Hashable) to the view declaration.

    In this case Fruit is hashable so we can just add .id(fruit)

        NavigationStack(path: $fruitViewController.visibilityStack) {
            ...
            .navigationDestination(for: Fruit.self) { fruit in FruitView(fruitViewController: fruitViewController, fruit: fruit).id(fruit)
            }
        } 
    

    I would say that Adkarma has a point. The view is not "deiniting" as one might expect because they've haven't simply loaded a new fruit. They've edited the actual navigation path.

    The thing is the view's identity is being preserved because (simplification/educated guess warning) it is NavigationStack[0] as? FruitView and NavigationStack[0] as FruitView is still there. The NavigationStack will not fully clean it up until the RootView has reappeared. NavigationStack doesn't clean up any views until the new one has appeared.

    So, if you change FruitViewController to:

        func openView(fruit: Fruit) {
            visibilityStack.append(fruit)
        }
    

    The NavigationStack will work as expected and you will continue to add views to the stack - I've added a counter and the visibilityStack to the FruitView to make what's happening clearer as well as some messages to make it clearer when things get made and cleaned up.

    struct ContentView: View {
        @StateObject private var fruitController = FruitViewController()
        
        var body: some View {
            NavigationStack(path: $fruitController.visibilityStack) {
                VStack {
                    ...
                }
                .onAppear() {
                    print("RootView Appeared")
                }
                .navigationDestination(for: Fruit.self) {
                    fruit in FruitView(fruit: fruit).environmentObject(fruitController)
                }
            }
        }
    
    struct FruitView: View {
        // the state that will change and not be initialised
        @State private var showMoreText = false
        @State private var counter = 0
        @EnvironmentObject var fruitViewController: FruitViewController
        var fruit: Fruit
        
        var body: some View {
            VStack {
            Text("Selected fruit: " + fruit.id)
            Text("How many updates: \(counter)")
            if (showMoreText) {
                Text("The text should disappear when moving to another fruit")
                HStack(spacing: 10) {
                    Text("Who is in the fruitList")
                    ForEach(fruitViewController.fruitList) {
                        aFruit in Button(aFruit.id) {
                            counter += 1
                            fruitViewController.openView(fruit: aFruit)
                        }
                    }
                }
                
                HStack(spacing:10) {
                    Text("Who is in the visibility stack")
                    ForEach(fruitViewController.visibilityStack) {
                        aFruit in Button(aFruit.id) {
                            counter += 1
                            fruitViewController.openView(fruit: aFruit)
                        }
                    }
                    
                }
            } else {
                Button("Show other fruits", action: showButtons)
            }
            }.onDisappear() {
                print("Fruit view \(fruit.id) is gone")
            }.onAppear() {
                print("Hi I'm \(fruit.id), I'm new here.")
                //dump(env)
            }
        }
        
        // let's change the state
        func showButtons() {
            showMoreText = true
        }
    }
    
    

    But this doesn't seem to be the behavior Adkarma wants. They don't want to go deeper and deeper. They want a hard swap at the identical position? Correct? The NavigationStack seems to try to increase efficiency by not destroying an existing view of the same type, which of course leaves the @State objects intact.

    The navigation path is being driven by a binding to an Array inside an ObservableObject. The NavigationStack continues to believe that it is at fruitViewController.visibilityStack[0] ... because it is. It doesn't seem to care about the content inside the wrapper beyond its Type.

    The preserved FruitView will re-run the body code, but since it isn't a "new view" (It's still good old NavigationStack[0] as FruitView)it will not hard refresh the @State vars.

    But "I zero'd that out!" you say, but NavigationStack seems to have copy of it that DIDN'T, and it won't until it can make a new view appear, which it doesn't need to because there is in fact still something at NavigationStack[0] right away again.

    I think Adkarma is spot on that this is related to deep linking and here are some articles I quite liked about that:

    Also interesting:

    For what it's worth, to "get it done" I would just reset the variables in View when there is a content change in the Nav array explicitly checking with an onReceive, but I agree that seems a bit messy. I also would be interested if anyone has a more elegant solution to a hard swap of a navigation path that ends up at the same type of view at the same "distance" from the root like Adkarma's example.

    
    struct FruitView: View {
        // the state that will change and not be initialised
        @State private var showMoreText = false
        @State private var counter = 0
        @EnvironmentObject var fruitViewController: FruitViewController
        var fruit: Fruit
    
        var body: some View {
            VStack {
                Text("Selected fruit: " + fruit.id)
                Text("How many updates: \(counter)")
                if (showMoreText) {
                    Text("The text should disappear when moving to another fruit")
                    HStack(spacing: 10) {
                        Text("Who is in the fruitList")
                        ForEach(fruitViewController.fruitList) {
                            aFruit in Button(aFruit.id) {
                                counter += 1
                                fruitViewController.openView(fruit: aFruit)
                            }
                        }
                    }
                } else {
                    Button("Show other fruits", action: showButtons)
                }
            }.onReceive(fruitViewController.$visibilityStack) { value in
                counter = 0
                showMoreText = false
                
            }
            
        }
    
        // let's change the state
        func showButtons() {
            showMoreText = true
        }
    }
    
    

    FWIW a NavigationPath will also be looking at position/type pair rather than complete content so you will still need to look at it with .onRecieve or use an .id(some Hashable) on the view calls.

    Edited to add: A strange thing the following function "works" (it send the user to the root view for a split second and then navigates back out), up until the time is decreased under the amount of time it takes the rootView to load.

        func jumpView(fruit: Fruit) {
            visibilityStack.removeAll()
            Task { @MainActor in
                //The time delay may be device/app specific. 
                try? await Task.sleep(nanoseconds: 500_000_000)
                visibilityStack.append(fruit)
            }
        }