How does navigation work in SwiftUI with NavigationStack?
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.
NavigationPath vs a typed array
| Option | Use when |
|---|---|
[Route] | One enum covers all screens. Easy to test and to inspect. |
NavigationPath | Mixed value types. It is type-erased. |
NavigationPath can be saved with codable if all values are Codable. This helps with state restoration.
Deep links
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 aBool.navigationDestination(item:)(iOS 17) for a push driven by an optional.NavigationSplitViewfor two or three columns on iPad and Mac. It collapses to a stack on iPhone.TabViewwith theTabtype (iOS 18). Put a separateNavigationStackinside each tab.
Common mistakes
- Putting
navigationDestinationinside a lazy container likeList. Put it on the view outside the list. - Nesting a
NavigationStackinside 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
Routeras plain data. Assert thepathafter 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
UINavigationControllerand pushUIHostingControllerscreens.