Why first View on my NavigationStack appears again when I switch branch?

Hello,

In my app, I have an onboarding made of multiple steps in a NavigationStack.

I also have a state variable that controls an if else root branch to show either the onboarding NavigationStack or the app content if the onboarding is finished.

I noticed that when I end the onboarding (i.e. I switch to the other part of the if else root branch), the onAppear of the first View in the NavigationStack of the onboarding is called again. I don’t understand why.

Is this a bug?

Thanks, Axel

enum Step {
    case one
    case two
    case three
    case four
}

struct ContentView: View {
    @State private var isFinished: Bool = false
    @State private var steps: [Step] = []
    
    var body: some View {
        if isFinished {
            Button("Restart") {
                steps = []
                isFinished = false
            }
        } else {
            NavigationStack(path: $steps) {
                VStack {
                    Text("Start")
                        .onAppear { print("onAppear: start") }
                    
                    Button("Go to step 1") { steps.append(.one) }
                }
                .navigationDestination(for: Step.self) { step in
                    switch step {
                    case .one:
                        Button("Go to step 2") { steps.append(.two) }
                            .onAppear { print("onAppear: step 1") }
                        
                    case .two:
                        Button("Go to step 3") { steps.append(.three) }
                            .onAppear { print("onAppear: step 2") }
                        
                    case .three:
                        Button("Go to step 4") { steps.append(.four) }
                            .onAppear { print("onAppear: step 3") }
                        
                    case .four:
                        Button("End") {
                            isFinished = true
                        }
                        .onAppear { print("onAppear: end") }
                    }
                }
            }
            .onAppear { print("onAppear: NavigationStack") }
        }
    }
}

.onAppear { print("onAppear: start") } is attached to the VStack, so I'd expect that will be displayed regardless of the value in steps.

However, even if you attached it instead to the Button inside that VStack I would think it is still displayed because it's in the view until isFinished becomes true.

When you get to step four, press the End button and set steps = [], at that point the navigation stack has nothing in the switch statement so it's going to display the VStack, but then the view is redrawn because finished is now true.

I'm not sure it's a bug, but you could raise it and Apple might be able to re-jig the inner workings so that part of your if statement is completely ignored when finished becomes true.

Thanks @darkpaw. I moved the .onAppear { print("onAppear: start") } to the Text("Start"). The result is the same.

I also removed the line that removes the steps in the "End" button action, and moved it to the "Restart" button. The result is the same. In this case, the NavigationStack should not pop back to its initial view (the VStack), so I still don't get why this NavigationStack root view appears again.

It's not really when it appears - as you can see in your example, because the Start text is not actually displayed again.

It's when the View is added to the view cycle.

So, as I said, the NavigationStack is displayed, you go through the steps, and then there's nothing for it to display but it is still in the view lifecycle until isFinished becomes true.

That's why I said it's not really a bug, but you could raise it.

Alternatively, someone else who knows what they're talking about could jump in 😃

However, you can use my own code to get around this...

enum Step {
	case one
	case two
	case three
	case four
}

struct ContentView: View {
	@State private var isFinished: Bool = false
	@State private var steps: [Step] = []

	var body: some View {
		if isFinished {
			Button("Restart") {
				steps = []
				isFinished = false
			}
		} else {
			NavigationStack(path: $steps) {
				VStack {
					Text("Start")
						.onFirstAppear { print("onFirstAppear: start") }

					Button("Go to step 1") { steps.append(.one) }
				}
				.navigationDestination(for: Step.self) { step in
					switch step {
						case .one:
							Button("Go to step 2") { steps.append(.two) }
								.onFirstAppear { print("onFirstAppear: step 1") }

						case .two:
							Button("Go to step 3") { steps.append(.three) }
								.onFirstAppear { print("onFirstAppear: step 2") }

						case .three:
							Button("Go to step 4") { steps.append(.four) }
								.onFirstAppear { print("onFirstAppear: step 3") }

						case .four:
							Button("End") {
								isFinished = true
							}
							.onFirstAppear { print("onFirstAppear: end") }
					}
				}
			}
			.onFirstAppear { print("onFirstAppear: NavigationStack") }
		}
	}
}

#Preview {
    ContentView()
}


extension View {
	func onFirstAppear(perform: @escaping () -> Void) -> some View {
		modifier(OnFirstAppear(perform: perform))
	}
}


private struct OnFirstAppear: ViewModifier {
	let perform: () -> Void

	@State private var firstTime = true

	func body(content: Content) -> some View {
		content.onAppear {
			if firstTime {
				firstTime = false
				perform()
			}
		}
	}
}

I don't understand why the NavigationStack is still in the view lifecycle when I tap on the "End" button in the last step and toggle the isFinished state. Here, the NavigationStack should not exist anymore because it's in another branch conditioned by the isFinished flag.

And I don't understand why you say the NavigationStack has nothing to display at the end of the onboarding. The path still contains the steps (now that i don't remove them from the path when tapping on "End").

The NavigationStack still exists because of this sequence of events:

  1. You've gone through all the steps in the onboarding and are at step .four.
  2. Then, when you click the End button, isFinished is set to true.
  3. steps still contains a value, and is not empty until you press the Restart button.
  4. If the whole view were refreshed at this point, then SwiftUI would likely not bother to regenerate the NavigationStack because isFinished is not false.
  5. As it is, SwiftUI has generated the entire view so that the instant you press Restart, the NavigationStack is present and ready and doesn't need to be generated. Thus, it's all smooth and lovely for your UI and users.
  6. As explained, the VStack is not actually displayed on-screen because SwiftUI knows it's not being displayed... yet, but it's there for when it's needed.

That's what it looks like anyway.

Like I said, raise a bug if you think it's a bug.

The problem is similar to the one described at https://forums.developer.apple.com/forums/thread/719521, where onAppear is triggered unexpectedly.

The following patterns can currently be summarized:

  • The navigation container must be within a conditional branch.
  • The navigation container needs to perform certain operations (such as entering a new navigation page or creating multiple navigation container instances).
  • Unexpected calls occur when switching to a branch that does not include the navigation container.

This issue occurs only in iOS. I have submitted feedback as well.

Details can be found here: https://fatbobman.com/en/posts/traps-and-countermeasures-for-abnormal-onappear-calls-in-swiftui/

Why first View on my NavigationStack appears again when I switch branch?
 
 
Q