iosswiftuinavigationtaskswiftui-navigationstack

Navigation issue in Swiftui iOS 18 skipping a page


I have an app that takes an ObservableObject known as AppState(), sets up as StateObject and is passed through to MenuView() and so on as an EnvironmentObject from the @main struct.

My MenuView makes use of the new TabView with TabSection and Tab. This is nested in a NavigationStack with navigationDestination() inside it and .alert() attached to the outside of it.

The primary use case for this attached Alert is to navigate to a specific page from ANY view within the app.

ContentView() navigates to PageTwo() by use of navigationDestination() which binds to an @EnvironmentObject updated within a Task (the actual app performs async operations, so this needs to be kept). Note that NavigationStack is also in this view and not the others.

On PageTwo(), navigating to PageThree() navigate the same way as before but will also start a timer for 10 seconds.

Once the timer is up, an alert will popup. This gives the option to navigate to the ThirdPage() again. This seems to work well from any View inside the app.

The issue I am running into though is, once navigation to PageThree() has occurred VIA THE ALERT, pressing the Back button in the top left will skip PageTwo() and go straight back to ContentView().

Undesired behaviour.

Is there something I am doing wrong? Am I setting up the navigation incorrectly? Have I handled the navigationDestinations incorrectly?

Min repro:

@main
struct FirstAppApp: App {
    
    @StateObject private var app = AppState()
    
    var body: some Scene {
        WindowGroup {
            MenuView()
                .environmentObject(app)
        }
    }
}

struct MenuView: View {
    
    @EnvironmentObject var app: AppState
    @State private var device = UIDevice.current.userInterfaceIdiom
    @AppStorage("MyAppTabViewCustomization")
    private var customisation: TabViewCustomization
    
    var body: some View {
        NavigationStack {
            Group {
                if app.showDisclaimer {
                    Legal()
                } else {
                    TabView {
                        ForEach(SidebarItem.allCases, id: \.self) { tab in
                            TabSection("Planning") {
                                Tab("Plan", systemImage: "house") {
                                    ContentView()
                                }.customizationID("plan")
                            }
                        }
                    }
                    .tabViewStyle(.sidebarAdaptable)
                    .tabViewCustomization($customisation)
                }
            }
            .navigationDestination(isPresented: $app.atisUpdated) {
                PageThree()
            }
        }
        .alert(isPresented: $app.newAtisAvailable) {
            Alert(
                title: Text("Press GO to navigate to page 3"),
                primaryButton: .default(Text("GO"), action: {
                    Task {
                        app.atisUpdated = true
                    }
                }),
                secondaryButton: .cancel(Text("STOP"), action: {
                    Task {
                        app.atisTimer.invalidate()
                    }
                })
            )
        }
    }
}

enum SidebarItem: String, Identifiable, CaseIterable {
    case planner = "Planner"
    var id: String { self.rawValue }
    var iconName: String {
        switch self {
        case .planner:
            return "house"
        }
    }
    var customizationID: String {
        switch self {
        case .planner:
            return "planner"
        }
    }
}


struct ContentView: View {
    
    @EnvironmentObject var app: AppState
    
    var body: some View {
        NavigationStack {
            VStack {
                Text("Page 1")
                Button("Go to page 2") {
                    Task {
                        app.readyToNavigate = true
                    }
                }
            }
            .navigationDestination(isPresented: $app.readyToNavigate) {
                PageTwo()
            }
        }
    }
}


struct PageTwo: View {
    @EnvironmentObject var app: AppState
    
    var body: some View {
        VStack {
            Text("Page 2")
            Button("Go to page 3") {
                Task {
                    app.atisButtonPressed = true
                    await app.startTimer()
                }
            }
        }
        .navigationDestination(isPresented: $app.atisButtonPressed) {
            PageThree()
        }
    }
}


struct PageThree: View {
    @EnvironmentObject var app: AppState
    
    var body: some View {
        NavigationStack {
            VStack {
                Text("Page 3")
            }
        }
    }
}



class AppState: ObservableObject {
    @Published var showDisclaimer: Bool = false // !UserDefaults.standard.bool(forKey: "DisclaimerAccepted")
    @Published var atisButtonPressed: Bool = false
    @Published var readyToNavigate: Bool = false
    @Published var atisUpdated: Bool = false
    @Published var atisTimer: Timer = Timer()
    @Published var newAtisAvailable: Bool = false

    @MainActor
    func startTimer() async {
        self.atisTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { _ in
            self.newAtisAvailable = true
        }
    }
}

Solution

  • The issue is because you have multiple NavigationStack.

    If you need more control over the path of the NavigationStack try using NavigationStack(path: $navigationPath) { ....} and change the path in your Views accordingly.

    Here is my example code that works for me.

    struct MenuView: View {
        @EnvironmentObject var app: AppState
        
        @State private var device = UIDevice.current.userInterfaceIdiom
        @AppStorage("MyAppTabViewCustomization")
        private var customisation: TabViewCustomization
        
        var body: some View {
            Group {
                if app.showDisclaimer {
                    Legal()
                } else {
                    TabView {
                        ForEach(SidebarItem.allCases, id: \.self) { tab in
                            TabSection("Planning") {
                                Tab("Plan", systemImage: "house") {
                                    ContentView()
                                }.customizationID("plan")
                            }
                        }
                    }
                    .tabViewStyle(.sidebarAdaptable)
                    .tabViewCustomization($customisation)
                }
            }
            .alert(isPresented: $app.newAtisAvailable) {
                Alert(
                    title: Text("Press GO to navigate to page 3"),
                    primaryButton: .default(Text("GO"), action: {
                        app.atisUpdated = true
                    }),
                    secondaryButton: .cancel(Text("STOP"), action: {
                        app.atisTimer.invalidate()
                    })
                )
            }
        }
    }
    
    struct ContentView: View {
        @EnvironmentObject var app: AppState
        
        var body: some View {
            NavigationStack {
                VStack {
                    Text("Page 1")
                    Button("Go to page 2") {
                        app.readyToNavigate = true
                    }
                }
                .navigationDestination(isPresented: $app.readyToNavigate) {
                    PageTwo()
                }
            }
        }
    }
    
    
    struct PageTwo: View {
        @EnvironmentObject var app: AppState
        
        var body: some View {
            VStack {
                Text("Page 2")
                Button("Go to page 3") {
                    Task {
                        app.atisButtonPressed = true
                        await app.startTimer()
                    }
                }
            }
            .navigationDestination(isPresented: $app.atisButtonPressed) {
                PageThree()
            }
        }
    }
    
    
    struct PageThree: View {
        @EnvironmentObject var app: AppState
        
        var body: some View {
            VStack {
                Text("Page 3")
            }
        }
    }
    
    struct Legal: View {
        var body: some View {
            Text("Legal")
        }
    }
    

    Note, the forums link you show in your comment does not say ...navstack in each view, it says NavigationStack for each Tab (that needs it). The meaning is NavigationStack should not be the top most View, it/they should be inside the TabView. Note also, you will have to adjust your .alert(...) code as I don't really understand what you want to achieve with it.

    EDIT-1:

    An alternative approach is to use NavigationStack(path: $app.path) {...} as shown in this example code:

    struct MenuView: View {
        @EnvironmentObject var app: AppState
        
        @State private var device = UIDevice.current.userInterfaceIdiom
        @AppStorage("MyAppTabViewCustomization")
        private var customisation: TabViewCustomization
        
        var body: some View {
            Group {
                if app.showDisclaimer {
                    Legal()
                } else {
                    TabView {
                        ForEach(SidebarItem.allCases, id: \.self) { tab in
                            TabSection("Planning") {
                                Tab("Plan", systemImage: "house") {
                                    ContentView()
                                }.customizationID("plan")
                            }
                        }
                    }
                    .tabViewStyle(.sidebarAdaptable)
                    .tabViewCustomization($customisation)
                }
            }
            .alert(isPresented: $app.newAtisAvailable) {
                Alert(
                    title: Text("Press GO to navigate to page 3"),
                    primaryButton: .default(Text("GO"), action: {
                        app.atisUpdated = true
                        app.path.append("Page3")  // <--- here
                    }),
                    secondaryButton: .cancel(Text("STOP"), action: {
                        app.atisTimer.invalidate()
                    })
                )
            }
        }
    }
    
    struct ContentView: View {
        @EnvironmentObject var app: AppState
        
        var body: some View {
            NavigationStack(path: $app.path) {  // <--- here
                VStack {
                    Text("Page 1")
                    Button("Go to page 2") {
                        app.readyToNavigate = true
                        app.path.append("Page2")  // <--- here
                    }
                }
                .navigationDestination(for: String.self) { page in  // <--- here
                    if page == "Page2" {
                        PageTwo()
                    } else if page == "Page3" {
                        PageThree()
                    }
                }
            }
        }
    }
    
    struct PageTwo: View {
        @EnvironmentObject var app: AppState
        
        var body: some View {
            VStack {
                Text("Page 2")
                Button("Go to page 3") {
                    app.atisButtonPressed = true
                    app.path.append("Page3")  // <--- here
                    Task {
                        await app.startTimer()
                    }
                }
            }
        }
    }
    
    struct PageThree: View {
        @EnvironmentObject var app: AppState
        
        var body: some View {
            VStack {
                Text("Page 3")
            }
        }
    }
    
    struct Legal: View {
        var body: some View {
            Text("Legal")
        }
    }
    
    class AppState: ObservableObject {
        @Published var showDisclaimer: Bool = false // !UserDefaults.standard.bool(forKey: "DisclaimerAccepted")
        @Published var atisButtonPressed: Bool = false
        @Published var readyToNavigate: Bool = false
        @Published var atisUpdated: Bool = false
        @Published var atisTimer: Timer = Timer()
        @Published var newAtisAvailable: Bool = false
        
        @Published var path = NavigationPath() // <--- here
        
        @MainActor
        func startTimer() async {
            self.atisTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
                self.newAtisAvailable = true
            }
        }
    }