Oskar Groth
Oskar GrothJune 9, 2022

Hands-on with NavigationStack in SwiftUI

Get to learn the new NavigationStack API in SwiftUI 4, and how to use it for simple and advanced navigation scenarios.
WWDC 2022

Background

Navigation is best described as the glue that binds your whole app together. Your navigation logic has to be able to coordinate views and their transitions across multiple hierarchies of various depths and view types.

For over a decade, the standard for building navigation flows in Mac or iOS apps has been to create a series of View Controllers that were managed by some sort of Navigation Controller. The typical classes to manage navigation were UINavigationController in UIKit, or a NSTabViewController in AppKit. They each served a different goal, with navigation on the smaller iOS-devices typically focusing on hierarchical systems where you drilled down into detail views, and Mac apps presenting content only on one level using a sidebar or tab bar.

The introduction of SwiftUI back in 2019 brought about a brand new standard for building navigation on Apple platforms. The new system was focused around two view structs, NavigationLink and NavigationView. By simply declaring a NavigationView, the operating system would create a UI component adapted for the specific platform, without any extra setup required.

To support navigation, the idea was to create a series of NavigationLink views that contained a destination view, as well as a closure to define a custom Label which would make up the appearance of the link (typically a button or list item).

NavigationView {
   NavigationLink(destination: Text("Second View")) {
      Text("Hello, World!")
   }
   .navigationTitle("Navigation")
}

Developers had very limited control over the behaviour here. We were able to use the NavigationViewStyle modifier to imply that we wanted a stack (hierarchical navigation) or column (split view navigation), but for the most part the experience was left for the OS to handle internally. At a first glance, this solution looked too good to be true, and it turned out to be just that.

NavigationView worked fine for very basic navigation. You could declare a link to a child view, and that child view could link to a deeper child view. But that is usually not enough for a real world use case, where our data models are dynamic and the app state will dictate what navigation flows are available. Even in UIKit, we could usually not depend on UINavigationController alone. Apps would employ certain patterns such as Coordinator or Router-pattern, usually built to allow additional logic to control complex navigation flows.

Because NavigationView was designed around views presenting views, it relied on each view having intrinsic knowledge about the next level of views it could present. It lacked logic for common use cases such as handling deep linking, where a user could navigate straight down the hierarchy without passing by the views in between.

On certain platforms like macOS, it also didn’t even support hierarchical push-pop navigation at all. This, together with a series of other similar limitations, had most developers finding it impossible to integrate into many real-world app architectures.

It seems that Apple has realised the shortcomings of NavigationView, because they've just presented yet another a brand new method for navigation in SwiftUI. NavigationView is now deprecated in favour of two new container types: NavigationStack and NavigationSplitView. The first thing to note is that these are now two distinct containers, as opposed to the single NavigationView that used different view styles for stack or split view.

In this article, we'll be taking a closer look at the NavigationStack API. NavigationStack defines navigation as a stack of either views or data objects. In the former case, we can use it similarily to how we would use the now-deprecated NavigationView, by declaring a NavigationLink that takes the user to a new view. Let’s take a look.

To play around with a simple NavigationStack example in SwiftUI 4, first let's define a data type to display some of the friends from my camera roll.

enum Friend: String, CaseIterable, Identifiable {
   case alex
   case boo
   case carl
   case emma
   case steve
 
   var id: String {
      return rawValue
   }
   var name: String {
      return rawValue.capitalized
   }
}
 
struct FriendView: View {
   let friend: Friend
 
   var body: some View {
      VStack {
         Image(friend.id)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 340, height: 300)
      }
      .navigationTitle(friend.name)
    }
}

Now, let’s present it using a NavigationStack.

struct ContentView: View {
   var body: some View {
      NavigationStack {
         VStack {
            ForEach(Friend.allCases) { friend in
               NavigationLink(friend.rawValue) {
                  FriendView(friend: friend)
               }
            }
         }
      }
      .navigationTitle("Friends")
   }
}
Simple NavigationStack

This first scenario is almost identical to how you would use the older NavigationView, but there is a key difference here: the default navigation on macOS is no longer a split view, but a hierarchical push-pop navigation.

Working with Stacks

Now, let’s get into the new advantages of using NavigationStack. Instead of declaring target views for our NavigationLinks inside of the body, we can use a new navigationDestination(for:destination:) modifier.

This new modifier is available on any view, and it allows us to define multiple destinations for a parent NavigationStack depending on a certain data type. We can now switch to using this method by simply changing our NavigationLink to instead pass on the data object that we know the next view wants as a parameter.

struct ContentView: View {
   var body: some View {
      NavigationStack {
         VStack {
            ForEach(Friend.allCases) { friend in
               NavigationLink(friend.rawValue, value: friend)
            }
         }
         .navigationDestination(for: Friend.self, destination: { friend in
            FriendView(friend: friend)
         })
      }
      .navigationTitle("Friends")
   }
}

Let’s stop to think about what we’ve done here. By decoupling the NavigationLink from the child view, we’ve opened up for a much more intuitive and flexible navigational structure. Instead of designing our navigation around links pointing to views, we have links that represents a data object, and we control what child view to use in a separate step for each data type.

Behind the scenes, NavigationStack will use our data to construct a stack that defines our navigation path. When we tap our link, NavigationStack appends the model to the path, and pushes the navigation destination representing that friend onto the stack.

Advanced scenarios

The most important part of NavigationStack is that it exposes access to the model path itself, so we can navigate programmatically by simply mutating the path. To take advantage of this, we first have to define our own path state locally in our parent view, and pass that into to the NavigationStack as a binding.

struct ContentView: View {
   @State private var path: [Friend] = []
 
   var body: some View {
      NavigationStack(path: $path) {
         (...)
      }
   }
}

With this setup, we can easily manipulate the navigation flow. We can initiate our view to show a certain friend immediately by just instantiating our path to path = [.carl], or create a function to display any series of views by just mutating path directly.

Want to go back to the root? Simply remove all of the the contents of the path.

func popToRoot() {
	path.removeAll()
}

Let’s try it out by changing our example to display all of our Friends, by instantiating our path to all cases of Friend.

@State private var path: [Friend] = Friend.allCases
Programmatic NavigationStack

The possibilities here are endless. One could imagine that in a real-world scenario, we would have an @ObservableObject navigation model class managing the path and powering one or several instances of NavigationStack, essentially acting as a router.

Summing it up

We've only scratched the surface of the new navigation APIs in SwiftUI 4, but we're super excited about the new improvements. Developers are finally in complete control of the navigation behaviours in SwiftUI, and the new API is super easy to use 🥳