How to use NavigationStack in SwiftUI

swiftui Sep 02, 2024
NavigationStack in SwiftUI

“NavigationView will be deprecated in a future version of iOS: use NavigationStack or NavigationSplitView instead.”

You might have seen this warning when using NavigationView. Apple deprecated NavigationView in favor of NavigationStack and NavigationSplitView for SwiftUI apps targeting iOS 16 and later.

Table of Contents

  1. Difference between NavigationStack and NavigationView
  2. What is NavigationStack SwiftUI
  3. How to go to a new view in SwiftUI using NavigationStack
    1. Navigation based on Views
    2. Navigation based on Data Types
  4. Programmatic navigation with NavigationStack
    1. Programatically push a new view
    2. Programatically pop a view
    3. Programatically go back to root view

 

Difference between NavigationStack and NavigationView

While NavigationView still functions currently, it's no longer the recommended approach for building navigation in SwiftUI. NavigationView offered a basic way to handle navigation, but it lacked the flexibility for more complex navigation scenarios. It relied heavily on the view hierarchy, which could be cumbersome for intricate navigation flows. Apple recommends using NavigationStack for single-column navigation (common on iPhones) and NavigationSplitView for multi-column navigation (commonly used on iPadOS and macOS).

 

What is NavigationStack SwiftUI

NavigationStack manages a stack of views, with the root view always at the bottom. NavigationLink adds new views on top of the stack. Here's a simpler way to understand using a NavigationStack in SwiftUI:

Imagine a stack of plates. The bottom plate represents the main screen of your app (the root view). You can't remove it. Think of NavigationLink as a way to add new plates on top. When someone taps a NavigationLink, a new view is added to the top of the stack, just like placing a new plate on top.

Back buttons or swipe gestures remove views from the top, going back in navigation history ie, removing the topmost plate. The topmost plate (view) is always the one you see on the screen.

 

How to go to a new view in SwiftUI using NavigationStack

To go to a new view in SwiftUI, you first need to wrap the root view of your app's window scene within NavigationStack. This establishes the navigation hierarchy for your app.

struct ContentView: View {
  var body: some View {
    NavigationStack {
      // Your app's content goes here
    }
  }
}

Then, you'll have to use `NavigationLink` to add new views to the navigation stack. There are two main ways to achieve navigation using NavigationStack and NavigationLink.

This is the most straightforward approach. You directly link a NavigationLink to a specific destination view. When the user taps the link, the destination view is pushed onto the navigation stack, and the user navigates to that view.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            VStack(spacing: 40) {
                NavigationLink("Details") {
                    DetailsScreen()
                }
                NavigationLink("Profiles") {
                    ProfileScreen()
                }
                NavigationLink("Settings") {
                    SettingsScreen()
                }
            }
            .navigationTitle("Main View")
        }
    }
}

struct DetailsScreen: View {
    var body: some View {
        Text("Details Screen")
            .navigationTitle("Details")
    }
}

struct ProfileScreen: View {
    var body: some View {
        Text("Profile Screen")
            .navigationTitle("Profile")
    }
}

struct SettingsScreen: View {
    var body: some View {
        Text("Settings Screen")
            .navigationTitle("Settings")
    }
}

Result:

This code builds a simple app with different screens. The main screen ("Main View") has buttons for Details, Profiles, and Settings. The NavigationStack will contain the root view ie, the ContentView. Whenever the user navigates to any of the screens(Details, Profile, or Settings), that view is also pushed to the stack. When navigating back to the Main View, the newly added view is popped out, remaining just the ContentView in the stack.

 

This approach offers more flexibility. You associate a data type with a NavigationLink using the navigationDestination(for:destination:) modifier. Then, you can create multiple NavigationLink instances presenting different data of the same type. The navigationDestination defines the view to be displayed based on the specific data type.

Parameters 

  • data: The type of data that this destination matches.
  • destination: A view builder that defines a view to display when the stack's navigation state contains a value of type data. The closure takes one argument, which is the value of the data to present.
enum NavigationDestinations: String, CaseIterable, Hashable {
    case Details
    case Profiles
    case Settings
}

struct ContentView: View {
    let screens = NavigationDestinations.allCases
    var body: some View {
        NavigationStack {
            VStack(spacing: 40) {
                ForEach(screens, id: \.self) { screen in
                    NavigationLink(value: screen) {
                        Text(screen.rawValue)
                    }
                }
            }
            .navigationTitle("Main View")
            .navigationDestination(for: NavigationDestinations.self) { screen in
                switch screen {
                case .Details:
                    DetailsScreen()
                case .Profiles:
                    ProfileScreen()
                case .Settings:
                    SettingsScreen()
                }
            }
        }
    }
}

Result:

In this example, an enum called NavigationDestinations listing the different screens ("Details", "Profiles", and "Settings") is defined. Instead of separate navigation links for each screen, the ContentView uses a single navigationDestination modifier.  This modifier takes the chosen  NavigationDestinations enum case and uses a switch statement to display the corresponding screen. This also works the same as before. This separates navigation logic from the view hierarchy.

 

In case you have multiple different data types, you will have to specify multiple destinations. For example,

struct ContentView: View {
    let screens = NavigationDestinations.allCases
    var body: some View {
        NavigationStack {
            VStack(spacing: 40) {
                ForEach(screens, id: \.self) { screen in
                    NavigationLink(value: screen) {
                        Text(screen.rawValue)
                    }
                }
                NavigationLink(value: "Search") {
                    Text("Search")
                }
            }
            .navigationTitle("Main View")
            .navigationDestination(for: NavigationDestinations.self) { screen in
                switch screen {
                case .Details:
                    DetailsScreen()
                case .Profiles:
                    ProfileScreen()
                case .Settings:
                    SettingsScreen()
                }
            }
            .navigationDestination(for: String.self) { text in
                SearchScreen(text: text)
            }
        }
    }
}

struct SearchScreen: View {
    let text: String
    var body: some View {
        Text("\(text) Screen")
            .navigationTitle(text)
    }
}

Result:

An additional navigationDestination is added, this time for the String type. This allows handling the "Search" screen navigation. When the user clicks the "Search" button, the destination value is the string "Search". The navigationDestination for String captures this value and uses it to create a SearchScreen instance, passing the received string ("Search") as the search text.

In this case the navigation works for both the data types - the NavigationDestinations enum and for the String. This helps us for scalable apps because you might have different data types and you can switch between your views based on the data type.

 

Programmatic navigation with NavigationStack

You can also navigate programmatically using NavigationPath within NavigationStack. NavigationPath is a collection type that represents the navigation history within a NavigationStack. This allows you to push or pop specific views in the navigation stack based on data.

Bind the NavigationStack's path property to a @State variable of type NavigationPath in your view.

struct ContentView: View {
    let screens = NavigationDestinations.allCases
    @State private var navigationPath = NavigationPath()
    var body: some View {
        NavigationStack(path: $navigationPath) {
            VStack(spacing: 40) {
                ForEach(screens, id: \.self) { screen in
                    NavigationLink(value: screen) {
                        Text(screen.rawValue)
                    }
                }
            }
            .navigationTitle("Main View")
            .navigationDestination(for: NavigationDestinations.self) { screen in
                switch screen {
                case .Details:
                    DetailsScreen()
                case .Profiles:
                    ProfileScreen()
                case .Settings:
                    SettingsScreen()
                }
            }
        }
    }
}

This navigationPath variable helps to track all the navigation. The NavigationStack uses the $navigationPath binding to connect the internal navigation state with the navigationPath variable. This allows for two-way communication between the UI and the navigation stack. In practice, it works very similarly to an array containing all the destinations navigated to reach a particular screen. For example, when navigating to DetailsScreen, the navigationPath will be containing two elements ie, the rootView(ie, the ContentView) and the DetailsScreen.

It also enables the definition of a pre-configured navigation route to a new destination, while also maintaining a history of all the traversed screens. For example, update the navigationPath with some pre-configured routes like this:

@State private var navigationPath = [NavigationDestinations.Details, NavigationDestinations.Settings, NavigationDestinations.Profiles]

Result:

In this case, the app starts with "Details", "Settings", and then "Profiles" on the navigation stack and hence will automatically navigate to the ProfilesScreen when the app starts. Behind that we have the SettingsScreen and behind it we have the DetailsScreen and at the root, we have the MainView(ContentView).

By modifying the navigationPath array directly, you can programmatically push and pop screens from the navigation stack.

 

Programatically push a new view

Use the append(_:) method on the NavigationPath to add a new element representing the view you want to push. The new element can be any data type that conforms to Hashable.

.navigationDestination(for: NavigationDestinations.self) { screen in
    switch screen {
    case .Details:
        DetailsScreen()
    case .Profiles:
        ProfileScreen()
    case .Settings:
        SettingsScreen()
    }
    Button("Add view") {
        navigationPath.append(NavigationDestinations.Details)
    }
}

Result:

It uses the append(_:) method to add a new element, NavigationDestinations.Details, to the end of the navigation path array. Clicking the "Add view" button will programmatically push the "Details" screen onto the navigation stack. This demonstrates how you can use the navigationPath variable to control navigation beyond just defining the initial state.

 

Programatically pop a view

Use the removeLast() method on the NavigationPath to remove the last element, effectively popping the topmost view from the stack.

.navigationDestination(for: NavigationDestinations.self) { screen in
    switch screen {
    case .Details:
        DetailsScreen()
    case .Profiles:
        ProfileScreen()
    case .Settings:
        SettingsScreen()
    }
    Button("Add view") {
        navigationPath.append(NavigationDestinations.Details)
    }
    Button("Back") {
        navigationPath.removeLast()
    }
}

Result:

Clicking the "Back" button will pop the topmost screen from the navigation stack. This allows users to navigate back in the history.

 

Programatically go back to root view

You can replace the entire navigation stack with a new path using navigationPath = NavigationPath(). This pops all screens from the navigation stack, essentially taking the user back to the starting point i.e the root view.

.navigationDestination(for: NavigationDestinations.self) { screen in
    switch screen {
    case .Details:
        DetailsScreen()
    case .Profiles:
        ProfileScreen()
    case .Settings:
        SettingsScreen()
    }
    Button("Add view") {
        navigationPath.append(NavigationDestinations.Details)
    }
    Button("Back") {
        navigationPath.removeLast()
    }
    Button("Root View") {
        navigationPath = NavigationPath()
    }
}

Result:

 

Clicking the "Root view" button will reset the navigationPath to an empty state, essentially taking the user back to the "Main View" in this example.

 

Conclusion

NavigationStack is the new recommended approach for building navigation in SwiftUI apps targeting iOS 16 and later. These new components offer more control and flexibility for complex navigation flows. While NavigationView still functions for now, transitioning your projects to the new navigation system is recommended to ensure compatibility with future versions of iOS.

 

Where to go next?

In this article you learned what is NavigationStack, two navigation approaches and programmatic navigation using NavigationPath. To further enhance your SwiftUI codebase, we strongly recommend delving into core mobile engineering concepts like MVVM in SwiftUI and Dependency Injection in Swift.

Signup now to get notified about our
FREE iOS Workshops!