Better Navigation in SwiftUI with NavigationStack

Welcome to an exploration of NavigationStack, a powerful tool introduced in SwiftUI with iOS 16 and macOS 13. This guide will dive into the details of NavigationStack, illustrating its applications within your SwiftUI projects. You’ll learn how to present different views, manage navigation states, and navigate programmatically. By the end of this journey, you should have a comprehensive understanding of NavigationStack’s capabilities, enabling you to enhance the navigation experience in your applications. You can alternatively present views as popovers, sheets, confirmation dialogs and more. Get an overview of all these options in this blog post.

Key Take Aways:

  • The previous NavigationView has been replaced with new navigation APIs that are more flexible approach. They consist of NavigationStack and NavigationSplitView.
  • Navigation link with bindings for active and selection is deprecated in favor of using the navigation state and navigation stack path property.
  • The new navigation link is divided into two tools: navigation link for value-based navigation and navigation destination for specifying the destination view.
  • SwiftUI programmatic navigation has become much easier to implement and less buggy than with the older NavigationView.

NavigationStack in Use

To effectively illustrate the process of implementing NavigationStack in SwiftUI, let’s break down our exploration into three code examples.

First, let’s discuss the root view, or the first screen that will appear in your app. In this view, we create a NavigationStack and List of NavigationLinks. Each NavigationLink has a destination determined by the value given to it, which in this case is a String value.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Go to detail A", value: "Show AAAA")
                NavigationLink("Go to B", value: "Show BBB")
            }

            .navigationDestination(for: String.self) { textValue in
                DetailView(text: textValue)
            }
            .navigationTitle("Root view")
        }
    }
}

The navigationDestination function is called when a NavigationLink is tapped. The function uses the value provided by NavigationLink to determine what view to show next. In the above code, I am using 2 different values: “Show AAA” and “Show BBB”. These values are passed to the navigation destination view.

In the following code I am defining a detail view. This view will be displayed when a link is navigated. It’s a simple SwiftUI view that takes a String as a parameter and shows it in two Text views.

struct DetailView: View {
    let text: String
    var body: some View {
        VStack {
            Text("Detail view showing")
            Text(text)
        }
        .navigationTitle(text)
    }
}

Using multiple Navigation Destination View

Most likely you want to have different destination views. Let’s say I want to show different Integers. I have to use a NavigationLink with a value that is an integer in combination with a navigation destination view modifier that uses the same type. I am showing navigationlink destination text:

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Go to detail A", value: "Show AAAA")
                NavigationLink("Go to B", value: "Show BBB")
                NavigationLink("Go to number 1", value: 1)
            }
            .navigationDestination(for: String.self) { textValue in
                DetailView(text: textValue)
            }
            .navigationDestination(for: Int.self) { numberValue in
                Text("Detail with \(numberValue)")
            }
             .navigationTitle("Root view")
        }
    }
}

As you can see in the above example, I use 2 navigation destination view modifiers. You may now wonder how Swiftui navigation knows which destination to call for which link? It maps the navigation links with and destination by type. If I tap on a link button with an integer type, the destination with an integer type is used. If I tap on a link button with string type, the destination with sting type is used.

SwiftUI NavigationStack example

Multilevel navigation stack

Typically for navigation with stack based manner, you would want to navigate deeper and add more and more destination views on the stack. In the following example, I am adding Navigation Link buttons to the detail screen:

struct DetailView: View {
    let text: String

    var body: some View {
        VStack {
            Text("Detail view showing")
            Text(text)

            Divider()

            NavigationLink("Link to 3", value: 3)
            NavigationLink("Link to C", value: "CCCC")
        }
        .navigationTitle(text)
    }
}

I am using a value type of integer and string, which when tapped will open the navigation destination defined in the content view from above.

Free SwiftUI Layout Book

Get Hands-on with SwiftUI Layout.

Master SwiftUI layouts with real-world examples and step-by-step guide. Building Complex and Responsive Interfaces.

Show view programmatically with NavigationStack

One of the key features introduced in SwiftUI’s updated navigation API is the ability to achieve programmatic navigation using the NavigationStack. In this section, we will explore the power and flexibility of programmatic navigation and how it can be implemented effectively in SwiftUI applications.

It becomes essential in various scenarios, such as when you want to navigate back to the root view directly or implement deep linking within your app. To enable programmatic navigation, it is crucial to access the property that defines the navigation state.

The core property to focus on is the “path” property, which acts as a binding to a navigation path type. The navigation path represents the collection of data that forms the navigation stack. It is akin to an array-like structure that holds information about the views in the navigation stack.

You can access the path state from the navigation stack like so:

struct ContentView: View {
    @State private var path = NavigationPath()
    var body: some View {
        NavigationStack(path: $path) {
            List {
                NavigationLink("Go to detail A", value: "Show AAAA")
                NavigationLink("Go to B", value: "Show BBB")
                NavigationLink("Go to number 1", value: 1)
            }
            .navigationDestination(for: String.self) { textValue in
                DetailView(text: textValue)
            }
            .navigationDestination(for: Int.self) { numberValue in
                Text("Detail with \(numberValue)")
            }
             .navigationTitle("Root view")
        }
    }
}

Every time a new view is added to the navigation stack, a new value is addd to the path array. I am using NavigationPath as the type, which type erases the actual values. In the following screenshots you, I am showing how many views are on the stack:

SwiftUI NavigationStack with programmatic navigation

How to programmatically trigger going back to the root view

Navigating programmatically works by manipulating the path property. The state where the navigation stack shows the root view, is when the path is empty. If you set exactly this state, the stack will pop back to the root view. Here is an example for a back button:

Button {
    path = NavigationPath()
} label: {
    Text("go to root view")
}

If you want to go back one level, you can remove the last value from the path stack. This removes the most recently added view from the stack:

Button {
    if path.count > 0 {
        path.removeLast()
    }
} label: {
    Text("go back one view")
}

You can use this technique to create a custom back button, which I will show you in the next section.

How to create a custom back button

Continuing on the above example, I now want to create a custom back button for the detail view. First, I am to get rid of the default back button with `navigationBarBackButtonHidden`. Then I am adding a new button with the toolbar modifier. For iOS the placement is ´navigationBarLeading´, which does not exist on macOS. Instead, I am using ´ navigation´ placement on macOS.

struct DetailView: View {

    let text: String
    @Binding var path: NavigationPath

    var backButtonPlacement: ToolbarItemPlacement {
        #if os(iOS)
        ToolbarItemPlacement.navigationBarLeading
        #else
        ToolbarItemPlacement.navigation
        #endif
    }

    var body: some View {
        VStack {
            ...
        }
        .navigationTitle(text)
        .navigationBarBackButtonHidden()
        .toolbar {
            ToolbarItem(placement: backButtonPlacement) {
                Button {
                    path.removeLast()
                } label: {
                    Image(systemName: "chevron.left.circle")
                }
            }
        }

    }
}
SwiftUI NavigationStack with custom navigation back button

In the code above, I added a navigation bar button, that acts as a custom back button.

In order to programmatically trigger going backwards, I need to access the path from within the detail screen. You can pass modifiable values in navigation views by using bindings. Inside Detail view I declared a binding variable:

@Binding var path: NavigationPath

I am passing from the content view like so:

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
          ...
        }
        .navigationDestination(for: String.self) { textValue in
             DetailView(text: textValue, path: $path)
        }
    }
}

This then allows me to manipulate the navigation stack programmatically from within the detail view. The back button is programmatically poping backwards:

Button {
    path.removeLast()
} label: {
    Image(systemName: "chevron.left.circle")
}
Free SwiftUI Layout Book

Get Hands-on with SwiftUI Layout.

Master SwiftUI layouts with real-world examples and step-by-step guide. Building Complex and Responsive Interfaces.

Programmatically push to a new view

Instead of using navigation link buttons, you can also trigger a link programmatically by changing the path property. Let’s say from the root view I want to show a favorite button. When the button is tapped, I am adding a new value to the path:

struct ContentView: View {

    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
          ...
          Button {
               path.append("GGG")
           } label: {
               Text("Show Favorite")
           }
        }
        .navigationDestination(for: String.self) { textValue in
             DetailView(text: textValue, path: $path)
        }
    }
}

You can perform this kind of action from anywhere within your navigation view, or from inside a view model.

Easy Access to the navigation path from anywhere in your app

As you saw above, you most likely want to access the path property from anywhere within you app. I passed the path property from one view to the next. If your app is quite extensive, this can be come quite repetitive and cumbersome. A good alternative is to use the SwiftUI environment. Let’s create an environment object that handles app navigation:

class NavigationStateManager: ObservableObject {

    @Published var selectionPath = NavigationPath()
    
    func popToRoot() {
        selectionPath = NavigationPath()
    }
    func goToSettings() {
        ...
    }
}

I will create one instance of NavigationStateManager per scene and place it in the environment:

struct ContentView: View {

    @StateObject var nav = NavigationStateManager()

    var body: some View {
        NavigationStack(path: $nav.path) {
          ...
          Button {
               path.append("GGG")
           } label: {
               Text("Show Favorite")
           }
        }
        .navigationDestination(for: String.self) { textValue in
             DetailView(text: textValue)
        }
        .environmentObject(nav)
    }
}

I don´t pass the path property directly to the detail view. Instead, I access it from the environment inside the detail view:

struct DetailView: View {

  let text: String
  @EnvironmentObject var nav: NavigationStateManager


  var body: some View {
    VStack {
        ...
    }
    .navigationTitle(text)
    .navigationBarBackButtonHidden()
    .toolbar {
        ToolbarItem(placement: backButtonPlacement) {
            Button {
               nav.path.removeLast()
            } label: {
                Image(systemName: "chevron.left.circle")
            }
        }
    }
}

Changing the Transitions for Pushing/Poping views

When you tap on a link, a new view moves in from the right edge of the screen and overlays the current view. When you press the back button, the current view is moved outside the screen towards the right edge. This behavior is the default and cannot be changed (at least I did not see a way).

Even in UIKit with UINavigationViewController this is tricky because you would define your own UIViewControllerAnimatedTransitioning.

Customizing the Navigation Bar

New with iOS 16 and NavigationStack is the possibility to change the tab bar background color.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            FoodListView()
                .navigationTitle("Your Food List")
                .toolbarBackground(.yellow, for: .navigationBar)
                .toolbarBackground(.visible, for: .navigationBar)
        }
    }
}

I set the toolbar background color to yellow inside content view. This also applies for TabView like so:

.toolbarBackground(.yellow, for: .tabBar)

I had to add the toolbar background to always visible, otherwise the bar would not be yellow initially. It would only become yellow while scrolling.

SwiftUI NavigationStack with custom bar background color

If you want to hide the tab bar background color, you can change the visibility to hidden:

.toolbarBackground(.hidden, for: .navigationBar)

Conclusion

The updated navigation API in SwiftUI, specifically the NavigationStack, has greatly improved the navigation capabilities in SwiftUI applications. In this blog post, we explored the significant enhancements introduced in iOS 16 and macOS 13.

I also delved into the benefits of programmatic navigation, leveraging the power of NavigationStack to dynamically manipulate the navigation state. By centralizing access to the navigation state and utilizing the path property, you can finely control the navigation flow based on user interactions or specific application logic.

The improved navigation stack not only simplifies navigation implementation but also enhances code maintainability and readability. With a single source of truth for navigation-related operations, you can design and develop SwiftUI applications with greater ease and confidence.

Further Reading:

4 thoughts on “Better Navigation in SwiftUI with NavigationStack”

  1. Apple Devs broke it in iOS 17.0… Can no longer removeLast() or removeAll() when using an array, as it flashes white… SwiftUI devs, after all these years, still cannot get basic navigation in an app working, it’s a joke.

    Reply
    • If anyone is still looking for this – you can add a .environmentObject modifier to the view in the preview, like so:

      #Preview {
      MyView()
      .environmentObject(“navigation”)
      }

      Reply

Leave a Comment

Subscribe to My Newsletter

Want the latest iOS development trends and insights delivered to your inbox? Subscribe to our newsletter now!

Newsletter Form

New Course Announcement!!!

50% OFF Launch Sale

MacOS Development with SwiftUI