WWDC 22
The SwiftUI cookbook for navigation
This year SwiftUI adds new APIs for handling navigation which scale well from simple to complex UIs, include support for programmatic navigation and deep linking
The existing API
The existing API is based on links (NavigationLink
) embedded inside NavigationView
You have a list of navigation link buttons, each specifying the view it links to, and when the button is tapped, the specified view is pushed onto the stack
This works great for basic navigation, and you can continue using this pattern with the new API
To programmatically perform navigation, you could use the NavigationView
initializer that binds the state of the navigation link to a variable (using isActive
or selection
)
However, this only worked well in simple cases and didn't let you easily do things like deep-linking deep into the navigation hierarchy straight from the root, or moving back to the root from there
It also required you to keep separate bindings for each link, or at least each level of the hierarchy
The new navigation APIs
The new API lets you bind the state of the whole navigation stack to a single array managed by the root container
Navigation links push additional values into that array when they're selected
This allows you to quickly move through the hierarchy by modifying that array binding, e.g. to deep-link to a specific view by assigning a collection of values to it, or to pop the stack to the root view immediately by removing all values
Navigation containers:
The new API consists of a couple of new container views and a new version of the navigation link
The first container is NavigationStack
:
NavigationStack(path: $path) { RecipeDetail() }
NavigationStack
is used for simple push-pop navigation happening in a single container, like in iPhone apps
The second container is NavigationSplitView
:
NavigationSplitView { RecipeCategories() } detail: { RecipeGrid() }
NavigationSplitView
is perfect for multi-column apps like Mail or Notes on the Mac or iPad
It automatically adapts to a single-column view in contexts like iPhone, Apple TV or slide-over on the iPad
To create a three-column layout, use another version of the initializer:
NavigationSplitView { RecipeCategories() } content: { RecipeList() } detail: { RecipeDetail() }
NavigationSplitView
has plenty of options that let you customize things like column widths or sidebar presentation, or even programmatically show or hide columns
You can hear more about configuring a layout like this in the talk SwiftUI on iPad: Organize your interface
Navigation links:
Previously, a navigation link included a title and a destination view to be presented:
NavigationLink("Show detail") { DetailView() } NavigationLink("Show detail", destination: DetailView()) NavigationLink(destination: DetailView()) { Text(title) }
The new version of NavigationLink
still includes a title, but instead of a view to present, it provides a value associated with the link:
NavigationLink("Apple Pie", value: applePieRecipe)
Clicking the link pushes that value into its container's path array
However, the exact view that's pushed when the link is clicked is determined by the navigation stack that the link is contained in
Navigation patterns
Let's now look at the ways you can use these views to build navigation in your apps:
1. Basic stack:
The first pattern is a basic navigation stack like in the Settings app on the iPhone or Apple Watch
The main view presents a list of items, and when an item is selected, it pushes a detail view on the stack which fills the whole screen
This approach works on all platforms, but it's best suited for iPhone, Apple TV and Apple Watch
In the basic form, you can just replace the NavigationView
with NavigationStack
and use NavigationLinks
like before, with the destination view inline:
var body: some View { NavigationStack { List(Category.allCases) { category in Section(category.localizedName) { ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(recipe.name) { RecipeDetail(recipe: recipe) } } } } .navigationTitle("Categories") } }
To be able to perform navigation programmatically, assign a value to the navigation link instead:
ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(recipe.name, value: recipe) }
And the destination view definition is then pulled out of the link block and moved to a new .navigationDestination
modifier defined on the NavigationStack
container:
.navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) }
The modifier describes what view should be pushed onto the stack when a given value is pushed into container's path by any of the navigation links anywhere in the view hierarchy (or programmatically), for all values of the given type
The navigation stack's path starts out initially as an empty array []
When the navigation link is clicked:
- 1. It appends the assigned value of
Recipe
type into the path - 2. The navigation stack then checks the
navigationDestination
configuration which stores a mapping ofRecipe -> some View
to get an appropriate view to present - 3. The new view is pushed onto the view stack
When you press the "Back" button, the last item is removed from the path array and the top view on the stack is popped
You also need to create a binding of the navigation stack's path to a local state variable:
@State private var path: [Recipe] = []
β you can use an array of a specific type if you only use values of one type in the path; if you want to build mixed paths of multiple types, use the new NavigationPath
wrapper type instead
Pass a binding to NavigationStack
's initializer:
var body: some View { NavigationStack(path: $path) { ... } }
You can now modify the view stack by assigning to the state variable:
func showRecipeOfTheDay() { path = [dataModel.recipeOfTheDay] } func popToRoot() { path.removeAll() }
You can see the NavigationStack
in action in the talk Build a productivity app for Apple Watch
2. Multi-column presentation without stacks:
This interface is similar to the Mail app on the Mac and iPad
The layout consists of:
- a sidebar, initially hidden, which shows a list of categories
- the second column that shows a list of recipes
- the main area that shows the recipe details
This interface is great on larger devices like the iPad and Mac
It allows you to see more information at the same time on the screen
In the first column (sidebar), we add a list of navigation links for each category of recipes:
@State private var selectedCategory: Category? var body: some View { NavigationSplitView { List(Category.allCases, selection: $selectedCategory) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } content: { // ... second column ... } detail: { // ... detail view ... } }
Instead of binding a property to the navigation view's path like before, here we bind it instead to the selected item in the list, on the root level only
When a NavigationLink
is contained within a list with the same selection type as the navigation link's value, the link will automatically update the selection of the list when clicked
β see more about this in the SwiftUI on iPad: Organize your interface talk
In the second column (content), we show the same kind of list, listing recipes in the selected category, and again, we use NavigationLinks
inside with assigned values of the same type as the list's selection binding
When a recipe is selected in the second column, the recipe details will be shown in the main area:
@State private var selectedCategory: Category? @State private var selectedRecipe: Recipe? var body: some View { NavigationSplitView { // ... } content: { List(dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in NavigationLink(recipe.name, value: recipe) } .navigationTitle(selectedCategory?.localizedName ?? "Recipes") } detail: { RecipeDetail(recipe: selectedRecipe) } }
Note: unlike NavigationStack
, NavigationSplitView
*does not* keep a path property with a list of selected items that you can bind to β the navigation on this level is managed only through the selection bindings of the two lists:
func showRecipeOfTheDay() { let recipe = dataModel.recipeOfTheDay selectedCategory = recipe.category selectedRecipe = recipe }
NavigationSplitView
also does not use the .navigationDestination
modifier
In environments that don't support multi-pane views (iOS, tvOS and iPad apps in split view or slide-over), NavigationSplitView
renders as a single-pane NavigationStack
which pushes the selected views onto its stack
3. Putting the two modes together β a multi-column view with a stack:
Here, we're building a two-column layout like in the Photos app on the iPad and Mac:
- the left column (sidebar) shows a list of categories
- the main area will show recipe photos in a grid
- when a recipe is selected, a recipe details view will be pushed on the navigation stack within the detail area
To build this kind of layout, we use NavigationSplitView
and NavigationStack
together:
- 1. The first column displays a list like in the three-pane version
- 2. The detail area contains a navigation stack like in the first example
We track the state of the navigation using two variables: a list selection binding for the split view's first column, and a path of values for the stack view inside the second column:
@State private var selectedCategory: Category? @State private var path: [Recipe] = [] struct ContentView: { var body: some View { NavigationSplitView { List(Category.allCases, selection: $selectedCategory) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } detail: { NavigationStack(path: $path) { RecipeGrid(category: selectedCategory) } } } }
The detail view displays a grid of navigation buttons wrapping image tiles; when a tile is pressed, the detail screen for a recipe is pushed onto the stack within the detail pane, which is defined using NavigationLinks
and the .navigationDestination
modifier:
struct RecipeGrid: View { var category: Category? var body: some View { if let category = category { ScrollView { LazyVGrid(columns: columns) { ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(value: recipe) { RecipeTile(recipe: recipe) } } } } .navigationTitle(category.name) .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } else { Text("Select a category") } } }
To update navigation programmatically, assign the two bound variables to modify selection in the two panes:
func showRecipeOfTheDay() { let recipe = dataModel.recipeOfTheDay selectedCategory = recipe.category path = [recipe] }
Persisting navigation state
Navigation state can be persisted between launches with the use of Codable
and @SceneStorage
properties
We'll wrap the navigation state properties into a single model type, so that they can be persisted together to keep the state consistent
This model type will conform to Codable
to let us save & restore it
@SceneStorage
will be used to automatically persist it together with the scene
Looking at the last example (with both NavigationSplitView
and NavigationStack
), here's how we update it to bind the UI to the properties in the new model type:
@StateObject private var navModel = NavigationModel() var body: some View { NavigationSplitView { List(Category.allCases, selection: $navModel.selectedCategory) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } detail: { NavigationStack(path: $navModel.path) { RecipeGrid(category: navModel.selectedCategory) } } }
One thing to note about the NavigationModel
: the values used for navigation & list selection will often be your model data types, i.e. whole structs like Recipe
here, but we don't want to store the whole structs with all their properties as a part of the navigation path β it would be redundant, because we probably have all the recipe data stored somewhere else in some kind of database.
To store the navigation state, we only need some kind of references to them, like their IDs β so we're going to customize the Codable
conformance here to save the recipes in the path as their ID values and restore them back from that on load:
class NavigationModel: ObservableObject, Codable { @Published var selectedCategory: Category? @Published var path: [Recipe] = [] enum CodingKeys: String, CodingKey { case selectedCategory case recipePathIds } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory) try container.encode(path.map(\.id), forKey: .recipePathIds) } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.selectedCategory = try container.decodeIfPresent(Category.self, forKey: selectedCategory) let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds) // some recipes might have been deleted in the meantime, so skip those self.path = recipePathIds.compactMap { DataModel.shared[$0] } } // helper to serialize the model to/from JSON Data var jsonData: Data? { get { ... } set { ... } } // async sequence of updates (from the sample code project) var objectWillChangeSequence: AsyncPublisher<Publishers.Buffer<ObservableObjectPublisher>> { objectWillChange .buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest) .values } }
The one missing piece is persisting the model using @SceneStorage
Since SceneStorage
can only hold values of simple types like URL
, String
or Data
, and not complex object types, we need to manually restore the model from the persisted form and make sure it's kept in sync after every change; SceneStorage
will handle the persistence of the encoded value for us, but we need to handle the conversion to/from it
We do this by providing a .task
that will run when the view is displayed, which loads the model from the data
The task then subscribes to a sequence of changes from the model and encodes the model into storage every time it changes:
@StateObject private var navModel = NavigationModel() @SceneStorage("navigation") private var data: Data? var body: some View { NavigationSplitView { // ... } .task { if let data { navModel.jsonData = data } for await _ in navModel.objectWillChangeSequence { data = navModel.jsonData } } }
For more details and examples about the new APIs, check out the "Migrating to new navigation types" documentation article
See also "Bring multiple windows to your SwiftUI app" for info about opening new windows and scenes