The SwiftUI cookbook for navigation
Description: The recipe for a great app begins with a clear and robust navigation structure. Join the SwiftUI team in our proverbial coding kitchen and learn how you can cook up a great experience for your app. We’ll introduce you to SwiftUI’s navigation stack and split view features, show you how you can link to specific areas of your app, and explore how you can quickly and easily restore navigational state.
Deprecation notice
All previous navigation APIs are deprecated:
NavigationView
is replaced by two new containers,NavigationStack
andNavigationSplitView
(more on this later)NavigationLink
still exists, has a completely different API and behavior, and is no longer mandatory to use
New navigation APIs
Containers
- with the new containers, we have a single binding for managing their stack state/path
- this single binding represents all the values pushed onto the stack, think of it as an array of screens
- The new
NavigationLink
APIs append values to this binding - you can directly mutate this binding yourself (just add/remove elements from this state/path array)
- You can programmatically push/pop multiple screens at once
- You can programmatically deep link
- You can pop to the root view by removing all the items
- The new
Two new containers:
NavigationStack
- for a single push-pop stackNavigationSplitView
- for multi-column apps- automatically adapts into a single-column stack on iPhone, into Slide Over on iPad, Apple Watch and Apple TV
- provides configuration options that let you:
- customize column widths (see
navigationSplitViewColumnWidth(_:)
and similar modifiers) - control sidebar presentation and show/hide columns (see
NavigationSplitViewVisibility
and associated modifiers)
- customize column widths (see
NavigationSplitView
has two initializers:
- To create a two-column navigation split view, use
init(sidebar:detail:)
- To create a three-column view, use the
init(sidebar:content:detail:)
NavigationStack(path: $path) {
NavigationLink("trigger", value: value)
}
NavigationSplitView {
List(model.employees, selection: $employeeIds) { employee in
Text(employee.name)
}
} detail: {
EmployeeDetails(for: employeeIds)
}
NavigationLink
- appends elements onto the stack it appears in
- no longer accepts a
destination
parameter (we use the newnavigationDestination(for:destination:)
modifier instead) - is no longer needed, programmatically append things to the
$path
state yourself instead
navigationDestination(for:destination:)
modifier
- declares the type of the presented data that it's responsible for
- takes in a view builder that describes what view to push onto the stack when a instance of that data is presented
NavigationStack {
List {
NavigationLink("Mint", value: Color.mint)
NavigationLink("Pink", value: Color.pink)
NavigationLink("Teal", value: Color.teal)
}
.navigationDestination(for: Color.self) { color in
ColorDetail(color: color) // The view to be pushed
}
.navigationTitle("Colors")
}
Explanation: when a navigation link is tapped, a new
Color
value is appended to theNavigationStack
internal$path
state,NavigationStack
then asks thenavigationDestination(for:destination:)
modifier associated withColor
for the view to be pushed.
- Every navigation stack keeps track of a path that represents all the data that the stack is showing
- When the stack shows its root view, the path is empty
- the stack also keeps track of all the navigation destinations declared inside it, or inside any view pushed onto the stack
- if you'd like to use this path yourself (e.g., for programmatic navigation), use the new type-erasing
NavigationPath
collection where you can push values of different types
Example with explicit path:
struct ContentView: View {
/// The stack state.
@State private var path: [Int] = []
var body: some View {
// This navigation doesn't use NavigationLink! 🎉
NavigationStack(path: $path) {
NumberView(0, onGoToNextNumber: { path = [1] })
// 👇🏻 This modifier returns the view to be pushed for $path values of type Int
.navigationDestination(for: Int.self) { number in
NumberView(number, onGoToNextNumber: { path.append(number + 1) })
}
}
}
}
/// A view that displays a number and has a button triggering an action injected by
/// its parent view.
struct NumberView: View {
var number: Int
var onGoToNextNumber: () -> Void
init(_ number: Int, onGoToNextNumber: @escaping () -> Void) {
self.number = number
self.onGoToNextNumber = onGoToNextNumber
}
var body: some View {
Text("\(number)").font(.headline)
Button("Go to next number", action: onGoToNextNumber)
}
}
Persistent state
- encapsulate your navigation state into a
Codable
model - use
SceneStorage
to save and restore that state - restore data via
.task
:
// Use SceneStorage to save and restore
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
var body: some View {
NavigationSplitView { ... }
.task {
// restore state if present
if let data = data {
navModel.jsonData = data
}
// start an asynchronous for loop that will iterate
// whenever my navigation model changes. The body of
// this loop will run on each change, so I can use
// that to save my navigation state back to my scene
// storage data.
for await _ in navModel.objectWillChangeSequence {
data = navModel.jsonData
}
}
}
Tips
- Check Apple's Migrating to New Navigation Types article
List
,NavigationSplitView
, andNavigationStack
were made to mix together- put
navigationDestination(for:destination:)
modifiers within easy reach - start with
NavigationSplitView
when it makes sense