Understanding the navigation stack

Learn about the navigation stack, links, and how to manage navigation types in your app’s structure.

Overview

A NavigationStack is a container for your app’s navigation structure. Use a navigation stack to present a stack of views over a root view.

A NavigationStack exposes its state to your app with the path parameter of its initializer. To create a navigation stack with a path that you can control or track views on the navigation stack, use a NavigationPath or a Binding to a RandomAccessCollection and RangeReplaceableCollection that contains Hashable elements.

A NavigationPath is a type-erased collection on which you can store a heterogenous list of data. For homogenous data, use an Array instead. Because NavigationPath is type-erased, it can represent different types of data that correspond to a view in the navigation stack.

Another element of the navigation stack is the navigation destination, which encapsulates the views people can navigate to within your app.

You can present destinations on a NavigationStack using:

View-destination

Use the init(destination:label:) initializer to push a view directly onto the navigation stack. A view-destination link is fire-and-forget: SwiftUI tracks the navigation state, but from your app’s perspective, there are no stateful hooks indicating you pushed a view.

Value–destination

A value-destination indicates that you are pushing a value onto the path. SwiftUI uses the value you pushed to the path to determine the corresponding view using the navigationDestination(for:destination:) modifier. You use init(value:label:) initializer to append a value onto the navigation path and navigationDestination(for:destination:) modifier to map the data type of the path appended to a specific destination view. You can also programmatically push views onto a navigation stack using a navigationDestination(isPresented:destination:) modifier. The destination is stateful—the state is explicitly available to your app via the Boolean binding. In cases where the presentation state is better modeled by the absence or presence of a value rather than a Boolean, use navigationDestination(item:destination:). This modifier takes a binding to a nullable data model.

You can push a view onto a NavigationStack using NavigationLink(destination:label:). With this initializer, you specify both a label—displayed on the link itself—and a destination—displayed when someone taps the link.

Enclose a NavigationLink in a navigation structure higher up in the view-hierarchy—an ancestor view, for example. If this condition isn’t met, the link typically appears as disabled.

Below is an example with two links inside a NavigationStack:

struct DestinationView: View {
    var body: some View {
        NavigationStack {
            NavigationLink {
                ColorDetail(color: .mint, text: "Mint")
            } label: {
                Text("Mint")
            }
            
            NavigationLink {
                ColorDetail(color: .red, text: "Red")
            } label: {
                Text("Red")
            }
        }
    }
}

struct ColorDetail: View {
    var color: Color
    var text: String

    var body: some View {
        VStack {
            Text(text)
            color
         }
    }
}

In this example, tapping the label titled “Mint” pushes a ColorDetail(color: .mint, text: "Mint") view onto the navigation stack. The navigation stack contents are the root view (the NavigationLink itself) at depth 0, and ColorDetail(color: .mint, text: "Mint") at depth 1.

When you use init(destination:label:), note that:

  • SwiftUI tracks the navigation state and the content of the navigation path; however, there are no stateful hooks for your app that indicate when the system pushes a view.

  • Its state can’t be restored programmatically.

Use the stateful navigation techniques described in Manage navigation state and compose links to track when a navigation link triggers, instead of onAppear(perform:) or View/task(priority:_:).

Use a navigationDestination(isPresented:destination:) modifier to navigate programmatically by providing a binding to a Boolean value. For example, you can programmatically push ColorDetail view onto the stack:

struct DestinationView: View { 
    @State private var showDetails = false
    var favoriteColor: Color
    
    NavigationStack {
        VStack {
            Circle()
                .fill(favoriteColor)
            Button("Show details") {
                showDetails = true
            }
        }
        .navigationDestination(isPresented: $showDetails) {
            ColorDetail(color: favoriteColor, text: color.description)
        }
    }
}

Use this approach when you want to navigate based on toggling state rather than by people’s interaction, or when your app presents a one-off destination with a different data type than the homogenous path of the navigation stack.

When you add data to the navigation path, SwiftUI maps the data type to a view, then pushes it onto the navigation stack when someone taps the link. To describe the view the stack displays, use the navigationDestination(for:destination:) view modifier inside a NavigationStack.

The following example implements DestinationView as a series of navigation links:

NavigationStack {
    List {
        NavigationLink("Mint", value: Color.mint)
        NavigationLink("Red", value: Color.red)
    }
    .navigationDestination(for: Color.self) { color in
        ColorDetail(color: color, text: color.description)
    }
}

In the example above, SwiftUI uses the value type—in this case, Color—to determine the appropriate navigation destination. With value-based navigation, you can define a variety of possible destinations for a single stack When someone taps “Mint”, SwiftUI pushes ColorDetail view with a value .mint onto the stack.

Value-based navigation shines in scenarios with mixed destination types. You can extend your app to handle recipe-related content in addition to colors:

struct ValueView: View {
    private var recipes: [Recipe] = [.applePie, .chocolateCake]
    
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
                ForEach(recipes) { recipe in
                    NavigationLink(recipe.description, value: recipe)
                }
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color, text: color.description)
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

struct RecipeDetailView: View {
    var recipe: Recipe
    
    var body: some View {
        Text(recipe.description)
    }
}

enum Recipe: Identifiable, Hashable, Codable {
    case applePie
    case chocolateCake
    
    var id: Self { self }
    
    var description: String {
        switch self {
        case .applePie:
            return "Apple Pie"
        case .chocolateCake:
            return "Chocolate Cake"
        }
    }
}

In this example, the NavigationStack supports two destination types: Color for colors, and Recipe for recipes. SwiftUI determines the correct destination view based on the data type of the value from the navigation link.

Use navigationDestination(item:destination:) when you need to navigate to a view based on the presence of an item. When the item binding is non-nil, SwiftUI passes the value into the destination closure and pushes the view onto the stack. For example:

struct ContentView: View {
    private var recipes: [Recipe] = [.applePie, .chocolateCake]
    @State private var selectedRecipe: Recipe?
    
    var body: some View {
        NavigationStack {
            List(recipes, selection: $selectedRecipe) { recipe in
                NavigationLink(recipe.description, value: recipe)
            }
            .navigationDestination(item: $selectedRecipe) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

When a person taps a recipe, selectedRecipe value updates and SwiftUI pushes RecipeDetailView(recipe: recipe) onto the navigation stack. You can pop the view off the stack by setting selectedRecipe back to nil.

By default, a navigation stack manages state to keep track of the views on the stack. However, your app can share control of the state by initializing the stack with a binding to a collection of data values that you create.

Use init(path:root:), which takes a binding to a NavigationPath argument, when you want to observe the navigation state for this stack.

The NavigationPath data type is a heterogeneous collection type that accepts any Hashable values. You can add to the path by calling append(_:) or when people tap value-destination links such as init(value:label:).

When you push a value onto the stack using init(_:value:), you append the value to the path, as shown below:

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

    var body: some View {
        NavigationStack(path: $path) {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color)
            }
        }
    }
}

In this example, when someone activates a link, SwiftUI adds the corresponding value, such as Color.mint, to path. SwiftUI uses the State property called path to mange the state of the navigation stack.

init(path:root:) also provides an initializer in which the path parameter takes a Binding to a RandomAccessCollection and a RangeReplaceableCollection argument. You can store the path as a property in an object that leverages the Observable() macro data type, and use property observers such as willSet and didSet or the onChange(of:initial:_:) modifier to respond to changes when the value-destination link triggers.

In this case, the navigation path is a homogenous collection type that accepts a standard type, such as Array, or a custom data type as shown below:

@Observable
class NavigationManager {
    var path: [Color] = [] {
        willSet {
            print("will set to \(newValue)")
        }
        
        didSet {
            print("didSet to \(path)")
        }
    }
}

struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color, text: color.description)
            }
        }
    }
}

In the example above, the willSet and didSet property observers track when a navigation link triggers.

You can also use the reference to path variable to perform programmatic navigation. For example, you can pop a view off the stack:

@Observable
class NavigationManager {
    var path: [Color] = [] {
        willSet {
            print("will set to \(newValue)")
        }
        
        didSet {
            print("didSet to \(path)")
        }
    }
    
    @discardableResult
    func navigateBack() -> Color? {
        path.popLast()
    }
}

Use a standard type when your stack displays views that rely on a single type of data, and NavigationPath when you need to present multiple data types in a single stack, as in the following example:

struct ValueView: View {
    @State private var path = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $path) {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
                NavigationLink("Apple Pie", value: Recipe.applePie)
                NavigationLink("Chocolate Cake", value: Recipe.chocolateCake)
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color)
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

When composed together, the navigation APIs allow you to use both styles of links, depending on what works best.

Here, when someone taps on the link “View Mint Color”, SwiftUI pushes the value-based destination link onto the stack, followed by a view-destination link:

struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            NavigationLink("View Mint Color", value: Color.mint)
                .navigationDestination(for: Color.self) { color in
                    NavigationLink("Push Recipe View") {
                        RecipeDetailView(recipe: .applePie)
                    }
                }
        }
    }
}

After the code in this example runs, and someone clicks each NavigationLink, the navigation stack builds up with three views:

Root

The starting view of the NavigationStack.

Collection of values

A sequence of zero or more values, such as Color.mint, pushed onto the path. The values serve as identifiers or keys that SwiftUI uses to determine which views to present.

Collection of views

A sequence of views such as RecipeDetailView, added to the path. This view is enclosed in the navigation destination and displayed when someone taps the link.

SwiftUI keeps track of the entire navigation path. The underlying data structure looks like the following example:

Root  [Color.mint]  [RecipeDetailView]

Conceptually, SwiftUI stacks view-based destinations on top of the value-based destinations in the stack’s navigation path. For example, the code below replaces RecipeDetailView from the above example with a NavigationLink:

struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            NavigationLink("View Mint Color", value: Color.mint)
                .navigationDestination(for: Color.self) { color in
                    NavigationLink("Push Recipe View") {
                        NavigationLink("Push another view", value: Color.pink)
                    }
                }
        }
    }
}

When you run the revised example, the view-destination link is still on the top of the stack.

If you use a heterogenous or homogeneous path on the stack, you may observe changes to the navigation path over time, as shown below:

@Observable
class NavigationManager {
    var path: [Color] = [] {
        didSet {
            print("didSet to \(path)")
        }
    }
}

struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            NavigationLink("View Mint Color", value: Color.mint)
                .navigationDestination(for: Color.self) { color in
                    NavigationLink("Push Recipe View") {
                        RecipeDetailView(recipe: .applePie)
                    }
                }
        }
    }
}

When someone navigates through the app, it prints the following logs:

New path: []
New Path: [Color.mint]

The logs print because view-destination navigation links don’t cause any state changes that your app can observe. If you attempt to push a value while a view-destination link is on the stack, SwiftUI pops all view destinations and pushes the value’s destination onto the stack.

Restore state for navigation paths

State restoration for a navigation path enables you restore your interface to the previous interaction point during a subsequent launch, providing continuity for people using your app.

In iOS, state restoration is especially important at the window or scene level, because windows come and go frequently. For this reason, it’s important to think about state restoration for navigation path in the same way you handle restoring your app’s state at the window or scene level. See Restoring your app’s state with SwiftUI to learn about storing scene data.

Using Codable, you can manually persist and load the navigation stack path in one of two ways, depending on whether the path data type is homogeneous or heterogeneous. Store a homogenous path as in the following example:

@Observable
class NavigationManager {
    var path: [Recipe] = [] {
        didSet {
            save()
        }
    }
    
    /// The URL for the JSON file that stores the navigation path.
    private static var dataURL: URL {
        .documentsDirectory.appending(path: "NavigationPath.json")
    }
    
    init() {
        do {
            // Load the data model from the 'NavigationPath' data file found in the Documents directory.
            let path = try load(url: NavigationManager.dataURL)
            self.path = path
        } catch {
            // Handle error.
        }
    }
    
    func save() {
        let encoder = JSONEncoder()
        do {
            let data = try encoder.encode(path)
            try data.write(to: NavigationManager.dataURL)
        } catch {
            // Handle error.
        }
    }
    
    /// Load the navigation path from a previously saved state.
    func load(url: URL) throws -> [Recipe] {
        let data = try Data(contentsOf: url, options: .mappedIfSafe)
        let decoder = JSONDecoder()
        return try decoder.decode([Recipe].self, from: data)
    }
}

struct ContentView: View {
    @State private var navigationManager = NavigationManager()

    var body: some View {
        NavigationStack(path: $navigationManager.path) {
            List {
                NavigationLink("Mint", value: Color.mint)
                NavigationLink("Red", value: Color.red)
                NavigationLink("Apple Pie", value: Recipe.applePie)
                NavigationLink("Chocolate Cake", value: Recipe.chocolateCake)
            }
            .navigationDestination(for: Color.self) { color in
                ColorDetail(color: color, text: color.description)
            }
            .navigationDestination(for: Recipe.self) { recipe in
                RecipeDetailView(recipe: recipe)
            }
        }
    }
}

In the above example, when the path changes, didSet property observer triggers and the save function is called. The function saves the new path to disk enabling the app to restore it when initializing NavigationManager.

Store a heterogeneous path using NavigationPath, as shown in the following example:

@Observable
class NavigationManager {
    var path = NavigationPath() {
        didSet {
            save()
        }
    }
    
    /// The URL for the JSON file that stores the navigation path.
    private static var dataURL: URL {
        .documentsDirectory.appending(path: "NavigationPath.json")
    }
    
    init() {
        do {
            // Load the data model from the 'NavigationPath' data file found in the Documents directory.
            let path = try load(url: NavigationManager.dataURL)
            self.path = path
        } catch {
            // Handle error
        }
    }
    
    func save() {
        guard let codableRepresentation = path.codable else { return }
        let encoder = JSONEncoder()
        do {
            let data = try encoder.encode(codableRepresentation)
            try data.write(to: NavigationManager.dataURL)
        } catch {
            //Handle error.
        }
    }
    
    /// Load the navigation path from a previously saved data.
    func load(url: URL) throws -> NavigationPath {
        let data = try Data(contentsOf: url, options: .mappedIfSafe)
        let decoder = JSONDecoder()
        let path = try decoder.decode(NavigationPath.CodableRepresentation.self, from: data)
        return NavigationPath(path)
    }
}

In the example above, the save method checks path.codable for nullability. This value describes the contents of the path in a serializable format. It returns nil if any of the type-erased elements of the path don’t conform to the codable.

It’s important to perform this check because NavigationPath doesn’t require the data types to conform to Codable. NavigationPath only needs the types to conform to Hashable, and as a result, you can’t verify that the navigation path is a valid representation of Codable at compile time.

To learn more about navigation stacks, links and paths, see Bringing robust navigation structure to your SwiftUI app.