NavigationStack in SwiftUI

Helder Pinhal
Helder Pinhal
Aug 19 2022
Posted in Engineering & Technology

A new approach to navigation in iOS 16.

NavigationStack in SwiftUI

SwiftUI has been around since 2019 and revolutionized how we build modern user interfaces. Supporting macOS, iOS, watchOS and tvOS with a single code base and adaptive UI components, SwiftUI is an excellent and stable technology.

Despite the significant advantages of SwiftUI, it's not without its challenges. Although Apple has been addressing several problems over the years, one that lingered on was navigation.

Building a simple navigation flow is relatively easy with NavigationView and NavigationLink. For example, to navigate from an overview list to a detail view, we could whip up something like the following:

struct ContentView: View {
    var body: some View {
        NavigationView {
            OverviewView()
        }
    }
}

struct OverviewView: View {
    var body: some View {
        List(1..<11) { i in
            NavigationLink("Item no. \(i)") {
                DetailsView(number: i)
            }
        }
        .navigationTitle("Overview")
    }
}

struct DetailsView: View {
    let number: Int

    var body: some View {
        Text("This is the details view for item no. \(number)")
            .navigationTitle("Details")
    }
}

This view-driven approach fits quite well with the declarative syntax Apple decided to go for. However, navigation becomes more complex than it should be when we bring programmatic navigation into the picture.

The first solution we can aim for is setting up a state-bound NavigationLink. This allows us to manipulate a state variable, and the link becomes active according to it.

struct ContentView: View {
    @State private var detailsEnabled = false

    var body: some View {
        NavigationView {
            NavigationLink(isActive: $detailsEnabled) {
                DetailsView(number: 0)
            } label: {
                Text("Open the first details page.")
            }

            // more code ...
        }
    }
}

The downside of this approach is when we intend to manipulate a deeply nested navigation link. To gain access to it from a higher level, we would need to hoist the state to something like an application router. Using an ObservableObject injected into the environment would solve this problem, but it's insufficient for more complex navigation requirements. In some cases, developers returned to the good ol' UINavigationController to manage their navigation and bridge the UI to SwiftUI.

Enter the NavigationStack

Being aware of the challenges, Apple introduced the shiny new NavigationStack, deprecating the previous NavigationView. Despite being possible to keep using a view-driven approach, the NavigationStack brings us a state-driven approach instead.

The new NavigationStack can be created with a NavigationPath, which essentially describes a complete route representing the current navigation stack. Think of this route as a list of individual steps necessary to reach a given point.

For instance, the representation of the example above would be root / details (number). A more complex example for a retail app could be root / cart-summary / cart-products / product-details (product). Each component gets translated to the corresponding view and we can manipulate the path partially or as a whole.

To better illustrate the changes, the example above could be implemented like the following:

// 1. Create an application router for the NavigationStack.
final class Router: ObservableObject {
    @Published var path = NavigationPath()
}

struct ContentView: View {
    // 2. Create a router instance and bind it to the state of the ContentView.
    @State private var router = Router()

    var body: some View {
        // 3. Convert the NavigationView to the new NavigationStack.
        NavigationStack(path: $router.path) {
            OverviewView()
        }
        // 4. Expose the router in the environment to facilitate access in child views
        // so that we're able to modify the path if necessary.
        .environmentObject(router)
    }
}

At this point, the main navigation has been updated but each view must be combed through to find the eligible NavigationLinks that require some modifications. In this example, we only have the OverviewView to worry about:

struct OverviewView: View {
    var body: some View {
        List(1..<11) { i in
            // 1. The NavigationLink no longer decides the destination view. Instead it adds a value to the path.
            NavigationLink("Item no. \(i)", value: i)
        }
        .navigationTitle("Overview")
        // 2. The navigationDestination modifier decides which view to create based a given type.
        .navigationDestination(for: Int.self) { number in
            DetailsView(number: number)
        }
    }
}

With this approach, changes made to the path will force the view tree to recalculate which views should be shown or removed from the stack.

Furthermore, each view can be made responsible for deciding which navigational types it supports through the navigationDestination modifier. When misused, this approach can short circuit a navigation stack when there is no eligible modifier for a given type. On the more positive side, it's a better approach than a global application router without child paths that merely toggle between views.

Programmatic navigation

It's safe to say that having access to a NavigationPath hints at the possibility of modifying that directly. By doing so, the view tree will comb through each route component and attempt to decide which views to render based on the navigationDestination modifiers.

Considering we made the router accessible through the environment, we can acquire a reference to it via @EnvironmentObject:

struct OverviewView: View {
    @EnvironmentObject private var router: Router

    // ...
}

Assuming we have a button whose purpose is to navigate directly to the first detail view, it becomes as simple as:

Button("Navigate to first item") {
    router.path = NavigationPath([1])
}

In this case, we create a brand new NavigationPath where its route components only contain a single item, an Int with 1 for the value, which is the type our navigationDestination supports. To return to the root, we only need to provide a NavigationPath with no components.

Conclusion

As soon as applications grow larger and navigation requirements become increasingly more complex, the NavigationStack will come to the rescue! Sadly, as it's typical with Apple, these new components are only available starting on iOS 16.

Should you need to support an older version, it will be a while until you can leverage the latest and greatest tools. Until then, you might want to look at a backported version of the NavigationStack.

As always, we hope you liked this article and if you have anything to add, we are available via our Support Channel.

Keep up-to-date with the latest news