NavigationStack path problems

As NavigationLink(destination:isActive:label:) has been deprecated, I've been trying to migrate our code to NavigationStack(path:root:).

However, I've been having a few questions/issues with NavigationStack.

NavigationStack not working well inside a TabView

Having a NavigationStack as the root of a TabView tab is pretty common, even in Apple's default apps. But if a NavigationStack that is not the first tab has a not empty path the first time it's is displayed, you get the following message in the Xcode console.

Do not put a navigation destination modifier inside a "lazy” container, like List or LazyVStack. These containers create child views only when needed to render on screen. Add the navigation destination modifier outside these containers so that the navigation stack can always see the destination. There's a misplaced navigationDestination(for:destination:) modifier for type Destination. It will be ignored in a future release.

The example code below triggers this message when run. You don't even have to do any action for the message to appear.

import SwiftUI

struct ContentView: View {
    enum Tab { case one, two }
    enum Destination { case foo }

    @State var tab: Tab = .two
    @State var path: [Destination] = [.foo]

    var body: some View {
        TabView(selection: $tab) {
            Color.red
                .tabItem {
                    Label("Tab 1", systemImage: "tray.and.arrow.down.fill")
                }
                .tag(Tab.one)

            NavigationStack(path: $path) {
                Color.blue
                    .navigationDestination(for: Destination.self) { _ in
                        Color.green
                            .navigationBarTitleDisplayMode(.inline)
                    }
            }
            .tabItem {
                Label("Tab 2", systemImage: "tray.and.arrow.up.fill")
            }
            .tag(Tab.two)
        }
    }
}

The warning is incorrect. I'm not using any List or LazyVStack, and where .navigationDestination() is called does not seem incorrect to me. I can understand that the tabs in a TabView are created lazily, but what am I supposed to do instead?

The warning is also triggered if path starts empty but is modified before its tab is displayed. Am I not supposed to modified the path of a NavigationStack that is in a tab not currently displayed? In our app we have a button that changes what's in a different tab before switching to it. Is it a supported use case?

(Trying with the latest Xcode 16 beta, no warning appears in the console, but I'm not sure if it's because something was fixed, or just that the current betas don't display such warnings.)

Shouldn't navigationDestination() have an implicit .id(destination)?

In our app, we have a button that replaces what's displayed in a different tab before switching to it. And reimplementing it with NavigationStack(path:root:), I had multiple cases where the content on that different tab did not update properly. With some investigation, it seems it's because SwiftUI did not consider the new content different from the previous one. I fixed that by adding .id(destination) at the end of the closure passed to .navigationDestination(), but it seemed to me that that .id(destination) should have been implicit (like it is for for example ForEach elements).

For example, when running the example below, pressing the "Change" button changes the path but nothing visually changes. You replaced a instance of Foo with another instance of Foo so SwiftUI thinks it's the same and does not reset the state. Of course, if i was an instance variable, the content would be updated, but having a @State/@StateObject initialized that way does not seem that uncommon when fetching data from the network. As I explained above, enclosing switch destination { ... } inside a Group { ... }.id(destination) fixes the problem but having to do it explicitly seemed unnatural to me, and might end up in a behavior the developer does not expect.

import SwiftUI

struct ContentView: View {
    enum Tab { case one, two }
    enum Destination: Hashable {
        case foo(Int)
    }

    @State var tab: Tab = .one
    @State var path: [Destination] = [.foo(1)]

    var body: some View {
        NavigationStack(path: $path) {
            Color.blue
                .navigationDestination(for: Destination.self) { destination in
                    switch destination {
                    case .foo(let i):
                        Foo(i: i, path: $path)
                    }
                }
        }
    }
}

struct Foo: View {
    @State var i: Int
    @Binding var path: [ContentView.Destination]
    var body: some View {
        ZStack {
            Color.black
            VStack {
                Button("Change") {
                    path = [.foo(i + 1)]
                }
                Text(String(i))
                    .bold()
                    .foregroundStyle(Color.white)
            }
        }
        .navigationBarTitleDisplayMode(.inline)
    }
}

Did I misunderstood something?

No equivalent of dismiss() for pushing

To pop the latest element of the path (or close a modal), you can use @Environment(\.dismiss), but I could not find something similar for pushing to the active NavigationStack. You are maybe supposed to use NavigationLink(value:label:) but it won't let you do logging when triggered, you cannot use an existing ButtonStyle (and there's no NavigationLinkStyle), and you cannot activate it programmatically. So either you pass a Binding of your path to each child view that might need it, or you have to roll you own mechanism using @Environment (or @EnvironmentObject).

Is there any existing mechanism I'm missing?

Progress

In fact I have seen other problems with NavigationStack. For example, if you change NavigationPath's path during the animation of a previous change to it, the new change does not get reflected on screen. But trying on the latest Xcode beta, it seems it has have been fixed in iOS 18. So Apple is definitely improving NavigationStack. But as long as we have to support iOS 17 and below, we have to be careful and make sure we continue to test well on iOS 16-17.

NavigationStack path problems
 
 
Q