iosNudge

How does navigation work in SwiftUI with NavigationStack?

MediumAsked oftenSwiftUIArchitecture 2 min read

TL;DR

NavigationStack (iOS 16) makes navigation data-driven. The stack shows one screen per value in a path. Push = append. Pop = remove. navigationDestination(for:) maps a value type to a screen. NavigationView is deprecated. Use NavigationStack for phones and NavigationSplitView for sidebars.

The basic version

struct ContactsScreen: View {
    let contacts: [Contact]

    var body: some View {
        NavigationStack {
            List(contacts) { contact in
                NavigationLink(contact.name, value: contact)
            }
            .navigationTitle("Contacts")
            .navigationDestination(for: Contact.self) { contact in
                ContactDetail(contact: contact)
            }
        }
    }
}

The link only carries a value. The destination view is built lazily, only when pushed. The old NavigationLink(destination:) built destination views early. That was slow for big lists.

Programmatic navigation with a path

enum Route: Hashable {
    case contact(Contact.ID)
    case settings
    case editProfile
}

@MainActor @Observable
final class Router {
    var path: [Route] = []

    func open(_ route: Route) { path.append(route) }
    func back() { _ = path.popLast() }
    func popToRoot() { path.removeAll() }
}

struct RootView: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeScreen()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .contact(let id): ContactDetail(id: id)
                    case .settings: SettingsScreen()
                    case .editProfile: EditProfileScreen()
                    }
                }
        }
        .environment(router)
    }
}

Any screen can now do router.open(.settings). No view needs to know about the next one.

OptionUse when
[Route]One enum covers all screens. Easy to test and to inspect.
NavigationPathMixed value types. It is type-erased.

NavigationPath can be saved with codable if all values are Codable. This helps with state restoration.

Because navigation is just data, a deep link builds the path:

.onOpenURL { url in
    if let id = Contact.ID(url.lastPathComponent) {
        router.path = [.contact(id)]
    }
}

Sheets and full screen covers

Modals are separate from the stack. Drive them with optional state:

@State private var editing: Contact?

List(contacts) { contact in
    Button(contact.name) { editing = contact }
}
.sheet(item: $editing) { contact in
    EditContact(contact: contact)
}

Set editing = nil to close, or call @Environment(\.dismiss) inside the sheet.

Other useful APIs

  • navigationDestination(isPresented:) for a push driven by a Bool.
  • navigationDestination(item:) (iOS 17) for a push driven by an optional.
  • NavigationSplitView for two or three columns on iPad and Mac. It collapses to a stack on iPhone.
  • TabView with the Tab type (iOS 18). Put a separate NavigationStack inside each tab.

Common mistakes

  • Putting navigationDestination inside a lazy container like List. Put it on the view outside the list.
  • Nesting a NavigationStack inside another one.
  • Registering two destinations for the same type. Only one is used.

Common follow-up questions

  • How do you pop to root? Set the path to empty.
  • How do you test navigation? Test the Router as plain data. Assert the path after an action.
  • How does this compare to a UIKit coordinator? The router plays the same role. The path replaces calls to pushViewController.
  • Can you mix UIKit navigation? Yes. Many apps keep a UINavigationController and push UIHostingController screens.