Programmatic navigation in SwiftUI with NavigationPath and navigationDestination

Published on: May 22, 2024

One of the key features that was missing from SwiftUI when it first shipped was a good way to do programmatic navigation. There were some ways to handle this before iOS 16 introduced NavigationPath but it wasn’t very satisfying to use those APIs and they could be rather unreliable at times. To see an example, take a look at this post I wrote about handling deeplinks on iOS 14.

In this post, I’d like to revisit programmatic navigation through iOS 16’s NavigationPath API which is a huge leap forward in terms of developer experience and reliability at the same time.

In this post we’ll look at:

  • Understanding iOS 16’s navigation API
  • Navigating through a NavigationPath

Understanding iOS 16’s navigation API

On iOS 16, Apple introduced the NavigationStack view. This is a view that’s pretty much analogous to UIKit’s UINavigationController and it allows developers to implement stack-based navigation. This is the kind of navigation that you’ll actually find in most apps that allow you to navigate into items that are shown in a list for example.

A navigation view in iOS has a stack of views that it holds on to as a hierarchy of how the user got to where they currently are. For example, the root view might be a list, the next view might be a movie view and the next one might be a view where users can view the cast of a movie. Each view would exist on the stack and a user can navigate back one level by swiping from the edge of their screen.

I’m sure you’re familiar with the UX of this.

The stack of views that represents the navigation hierarchy wasn’t available to use until iOS 16. The main difference between UIKit’s UINavigationController and how NavigationStack manages its navigation is that in SwiftUI we can actually navigate based on models.

This means that we can map instances of, for example, a Movie model to a MovieView that can present a movie to the user.

Essentially this means that we can model a navigation hierarchy using model data rather than views.

Let’s take a look at an example of how we can set up a NavigationStack along with a detail page for a given model type. We won’t introduce a NavigationPath just yet. Behind the scenes our NavigationStack will manage its own path if we don’t provide one so we’ll just rely on that for now.

The code below defines a simple list view with NavigationLink views to enable navigation. Notice that the NavigationLink receives a value instead of a destination. Also, notice how we’re applying a navigationDestination view modifier to specify a destination view for our model.

struct ContentView: View {
  @State private var exercises: [Exercise] = Exercise.sample

  var body: some View {
    NavigationStack {
      ExercisesList(exercises: exercises)
        .navigationDestination(for: Exercise.self, destination: { exercise in
          ExerciseDetail(exercise: exercise)
        })
    }
  }
}

struct ExercisesList: View {
  let exercises: [Exercise]

  var body: some View {
    List(exercises) { exercise in
      NavigationLink(value: exercise, label: {
        ExerciseListItem(exercise: exercise)
      })
    }
    .navigationTitle("My exercises")
  }
}

What’s especially interesting here is where we apply the navigationDestination view modifier.

I chose to add it to my list. This means that any NavigationLink inside of my list with an instance of Exercise as its value will use the destination view that I provided as its view. This means that I can define my destination views all in one place which means that I can quickly reason about which view will be shown for a model.

If I were to define a second navigationDestination for the same model type on my List, that second destination would overwrite my first. This allows me to override the destination if needed so that each view can still explicitly define its own “exit views” but it’s not required. This is really powerful and allows for very flexible navigation setups.

At this point, we’re able to push new models onto our navigation stack’s navigation path using our navigation link and we’ve configured a destination view using the navigationDestination view modifier.

Now let’s set up a navigation path so we can start performing some programmatic navigation, shall we?

Navigating with a NavigationPath

A NavigationStack can be set up with a NavigationPath object which will allow you to gain control over the stack’s navigation hierarchy.

The simplest way to set up a NavigationPath is as follows:

struct ContentView: View {
  @State private var exercises: [Exercise] = Exercise.sample
  @State private var path = NavigationPath()

  var body: some View {
    NavigationStack(path: $path) {
      ExercisesList(exercises: exercises)
        .navigationDestination(for: Exercise.self, destination: { exercise in
          ExerciseDetail(exercise: exercise)
        })
    }
  }
}

With this code, we’re not yet doing anything to gain control of our navigation path. We’re just making an instance of NavigationPath and we pass a binding to NavigationStack. From now on, whenever we navigate to a new view, the model that’s used as a value will be added to the path we created.

Essentially, when a user taps on a NavigationLink, we take the model instance that was passed as a value and it’s added to the navigation path automatically.

We can pass any Hashable model as the value for a navigation destination and we can also mix models. So we could pass instances of Exercise, Int, String, and more to the same navigation path.

In fact, you normally don’t worry about which model types you pass. You just pass the model that you need to draw your destination view and you let the system handle everything else.

Let’s take a look at how we can replace our NavigationLink with a Button so we can manually append our model to the NavigationPath that we’ve created before.

We can create a binding to the NavigationPath and we pass it to the ExercisesList, allowing it to append new items to the path which will allow the NavigationStack to navigate to the destination for our model:

struct ContentView: View {
  @State private var exercises: [Exercise] = Exercise.sample
  @State private var path = NavigationPath()

  var body: some View {
    NavigationStack(path: $path) {
      // 1
      ExercisesList(exercises: exercises, path: $path)
        .navigationDestination(for: Exercise.self, destination: { exercise in
          ExerciseDetail(exercise: exercise)
        })
    }
  }
}

struct ExercisesList: View {
  let exercises: [Exercise]
  // 2
  @Binding var path: NavigationPath

  var body: some View {
    List(exercises) { exercise in
      Button(action: {
        // 3
        path.append(exercise)
      }, label: {
        ExerciseListItem(exercise: exercise)
      })
    }
    .navigationTitle("My exercises")
  }
}

Before I explain the code, let me say that I don’t think this is a good idea. The code was better with NavigationLink. That said, the point of this example is to demo putting items in a NavigationPath programmatically which we can do from a button handler.

First, we pass a binding to our navigation path to the list view. This means that now our NavigationStack and ExercisesList both have access to the exact same NavigationPath instance.

The ExercisesList was updated to take a binding to a NavigationPath, and we’ve swapped the NavigationLink out in favor of a Button. In the button handler, I call append with the Exercise model for the button on path. This will add the model to the path which will cause SwiftUI to navigate to the destination view for that model.

This is really cool!

In addition to appending elements to the path, we can actually remove items from the path too by calling remove on it.

We can even get the number of items on the path to implement a “pop to root” style function:

func popToRoot() {
  path.removeLast(path.count)
}

This function will remove all elements from the navigation stack’s path, only leaving its root to be displayed.

The API for NavigationPath is really flexible. You can even add multiple views in a single pass, resulting in the last added view becoming the top one and all others being part of the stack so the user sees them when they navigate back.

In Summary

With NavigationPath we’ve gained loads of power in terms of being able to navigate programmatically. By leveraging model-based navigation we can represent a navigation stack’s hierarchy as data rather than views, and we’re able to pass our NavigationPath around through bindings in order to allow views to append new models to the path.

Handling deeplinks and restoring navigation stacks with NavigationPath is loads better than it used to be pre iOS 16 and I’m sure that Apple will keep improving NavigationPath over time to make managing navigation through code better and better.

Categories

SwiftUI

Subscribe to my newsletter