Introducing NavigationPath in SwiftUI

I’m happy to share the news that this year at WWDC Apple finally gave us the navigation API for SwiftUI we’ve been waiting for since the framework was introduced in 2019.
If you follow me you may know I like programmatic navigation and I was never a fan of the API the team released with the first version of SwiftUI.
There are 3 blog post I wrote in the past: Navigation in SwiftUI deprecated, Navigation in SwiftUI and Dynamic navigation in SwiftUI where I proposed my solution to have a dynamic navigation in SwiftUI. Since the new API requires iOS 16 the latter post will still have reason to exist for compatibility reasons, the previous will stay on my blog just as a memento of what SwiftUI was at its very beginning.
In this article I’ll focus on stack based navigation and how you can perform it via NavigationStack and NavigationPath.

As usual, all you code of this post can be found on GitHub

NavigationStack

Suppose you want your app to have a stack based navigation. In the past you could have a NavigationView with a modifier, like this


NavigationView {
    // place your content here...
}
.navigationViewStyle(.stack)

while now you can simply use a NavigationStack


NavigationStack {
    // place your content here...
}

in order to go to another view, you can still use NavigationLink


NavigationStack {
    NavigationLink("go to second page", destination: {
        SecondPage()
    })
}

and specifying a destination, you can go directly to that view.
This resemble the old API and as I wrote before I’m not a big fan of having NavigationLink decide the destination in its initialiser.
You know have another option, using the navigationDestination modifier.
Take a look at ContentView in my sample project


struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack {
                Text("Navigation stack")
                    .padding()
                NavigationLink("NavigationLink to enter first page", value: Destination.firstPage)
                    .padding()
                NavigationLink("NavigationLink to enter second page", value: Destination.secondPage)
                    .padding()
                List(1..<3) { index in
                    NavigationLink("Nav Link \(index)", value: index)
                }
            }
            .navigationDestination(for: Destination.self) { destination in
                ViewFactory.viewForDestination(destination)
            }
            .navigationDestination(for: Int.self) { index in
                Text("index \(index)")
            }
        }
    }
}


there are two NavigationLink, but this time you can specify a value, not a destination, so the destination view is not bound to the specific NavigationLink like before.
When you tap on a NavigationLink, the navigationDestination modifier gets the value, and in this example I have a ViewFactory to return the next view based on the value of the Destination enum for the first two links.
You can have multiple navigationDestination modifiers, for different types. In the example above I have a List with 2 NavigationLink based on an Int index, and there is a navigationDestination for Int that will take care of those links.

I think I like this approach, but there is something even better that allow us to go back and forth in our navigation hierarchy.

NavigationPath

All right, the title says introduction to NavigationPath so it is finally time to talk about it 🙂
The new NavigationStack can be initialised with a Content, like we did in the previous example, or with a NavigationPath.
The great thing about using a NavigationPath, is that we can have it stored int an ObservedObject, so by changing the variable we are able to programmatically push and pop View into the stack!
That’s great news, because it allow us to have a good separation of concerns, now our view can have a Button to trigger an action, but an object (in my example I call it the Coordinator) can take care of business logic and decided what to push on the stack or to pop to a previous view or even to the root.

This is the main entry point of our sample app:


@main
struct SwiftUI4App: App {
    @ObservedObject var coordinator = Coordinator()
    
    var body: some Scene {
        WindowGroup {
            NavigationStack(path: $coordinator.path) {
                MainView()
                    .navigationDestination(for: Destination.self) { destination in
                        ViewFactory.viewForDestination(destination)
                    }
            }
            .environmentObject(coordinator)
        }
    }
}

we have a NavigationStack, and this time it is initialised with a NavigationPath stored into our Coordinator.
For simplicity, I put the coordinator as environment object, so the Views will have access to it. In a more complex app architecture you have have different coordinators, but for the sake of this article a shared one will do just fine.
Just like the previous sample, we have the navigationDestination modifier with the Destination enum, and our ViewFactory returns the View for that particular destination.
But how do we push a view? Let’s have a look at MainView


struct MainView: View {
    @EnvironmentObject var coordinator: Coordinator
    
    var body: some View {
        VStack {
            Text("This is the initial screen")
                .padding()
            Button {
                coordinator.tapOnEnter()
            } label: {
                Text("Button to enter")
            }
        }
    }
}


all we need is a Button and a call to the coordinator.
This is the coordinator


class Coordinator: ObservableObject {
    @Published var path = NavigationPath()
    
    func gotoHomePage() {
        path.removeLast(path.count)
    }
    
    func tapOnEnter() {
        path.append(Destination.firstPage)
    }
    
    func tapOnFirstPage() {
        path.append(Destination.secondPage)
    }
    
    func tapOnSecondPage() {
        path.removeLast()
    }
}

it has @Published variabile of type NavigationPath. To push a View we simply append a new value to the NavigationPath, and this triggers the call to navigationDestination with the value we just pushed.
And what about popping views?
To get back to the previous View, you can call removeLast. The function has an optional parameter, so you can pop more than a View at a time.
To get back to the root view controller you can simply call removeLast(path.count) so it removes everything.

If, like in this example, your path depends on a single type, you can use an Array instead of NavigationPath and it works the same way.


class Coordinator: ObservableObject {
    @Published var path = [Destination]()
    
    func gotoHomePage() {
        path.removeLast(path.count)
    }
    
    func tapOnEnter() {
        path.append(Destination.firstPage)
    }
    
    func tapOnFirstPage() {
        path.append(Destination.secondPage)
    }
    
    func tapOnSecondPage() {
        path.removeLast()
    }
}

NavigationPath uses type erasure so you can have a collection of different types, in this case remember you’ll need multiple navigationDestination modifiers to handle all the possible types.

I like it! By putting a NavigationStack into our main entry point and place the necessary navigationDestination modifiers there, we are able to control the navigation of our app in a single point. Our Views only need to have Buttons to trigger actions on a coordinator, and by changing a single variable we can push and pop views into the stack.
For multiple columns layout you can use NavigationSplitView, but this is outside the scope of this article.

That’s it for now. I hope you are as excited as I am about the new additions to SwiftUI, stay tuned for more post about those and happy coding! 🙂