Presenter:
- Curt Clifton, SwiftUI Engineer
New data-driven navigation APIs
The existing APIs are based on links that send views that are shown in other columns or on a stack. With existing navigation, to present a link programmatically, add a binding to the link. Lots of bindings.
New APIs push that up to the entire container, called a NavigationStack. The path here is a collection that represent all the values pushed on the stack:
NavigationStack(path: $path) {
NavigationLink("Details", value: value)
}
Good for multi-column apps on mac or ipad. Automatically adapts to a single-column stack.
Two-column experience:
NavigationSplitView {
RecipeCategories()
} content: {
RecipeList()
} detail: {
RecipeGrid()
}
Three-column experience:
NavigationSplitView {
RecipeCategories()
} detail: {
RecipeGrid()
}
Title and view to present (old style):
NavigationLink("Show detail") {
DetailView()
}
Title and value (new variant):
NavigationLink("Apple Pie", value: applePieRecipe)
The link's behavior depends on the context in which it appears.
Basic navigation (similar to existing navigation paradigms) - single stack
Use:
- NavigationStack
- NavigationLink (the value variant)
- .navigationDestination
struct PushableStack: View {
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationStack(path: $path) {
List(Category.allCases) { category in
Section(category.localizedName) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(recipe.name, value: recipe)
}
}
}
.navigationTitle("Categories")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
.environmentObject(dataModel)
}
}
If you need to use multiple types in a navigation stack, check out the new, type-erasing NaviationPath
for mixed data. With NavigationStack, you can:
Add a func to jump to a specific destination
@State private var path: [Recipe] = []
var body: some View {
NavigationStack(path: $path) { ... }
}
func showRecipeOfTheDay() {
path = [dataModel.recipeOfTheDay]
}
Create a func to pop back to the root by removing everything from the path stack
@State private var path: [Recipe] = []
var body: some View {
NavigationStack(path: $path) { ... }
}
func popToRoot() {
path.removeAll()
}
For a multi-column navigation with columns showing progressively more info, use:
- NavigationSplitView
- NavigationLink
- List
@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?
var body: some View {
NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
.navigationTitle("Categories")
} content: {
List(
dataModel.recipes(in: selectedCategory),
selection: $selectedRecipe)
{ recipe in
NavigationLink(recipe.name, value: recipe)
}
.navigationTitle(selectedCategory?.localizedName ?? "Recipes")
} detail: {
RecipeDetail(recipe: selectedRecipe)
}
}
Use a func to navigate to specific destination:
@State private var selectedCategory: Category?
@State private var selectedRecipe: Recipe?
var body: some View {
NavigationSplitView { ... }
}
func showRecipeOfTheDay() {
let recipe = dataModel.recipeOfTheDay
selectedCategory = recipe.category
selectedRecipe = recipe
}
Navigate between related information
Use:
- NavigationSplitView
- NavigationStack
- NavigationLink
- .navigationDestination(for: )
- List
// Multiple columns with a stack
struct MultipleColumnsWithStack: View {
@State private var selectedCategory: Category?
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
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)
}
}
.environmentObject(dataModel)
}
}
We can put a NavigationStack inside a column in the NavigationSplitView. So then:
struct RecipeGrid: View {
@EnvironmentObject private var dataModel: DataModel
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.localizedName)
// Don't attach this directly to the NavigationLink. Lazy loading in the LazyVGrid
// means the thing might not load, *and* it repeats for every item in grid.
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
} else {
Text("Select a category")
}
}
var columns: [GridItem] { [GridItem(.adaptive(minimum: 240))] }
}
And for the detail view:
struct RecipeDetail: View {
@EnvironmentObject private var dataModel: DataModel
var recipe: Recipe
var body: some View {
Text("Recipe details go here")
.navigationTitle(recipe.name)
ForEach(recipe.related.compactMap { dataModel[$0] }) { related in
NavigationLink(related.name, value: related)
}
}
}
Method to show recipe of the day with this navigation paradigm:
@State private var selectedCategory: Category?
@State private var path: [Recipe] = []
var body: some View {
NavigationSplitView { ... }
}
func showRecipeOfTheDay() {
let recipe = dataModel.recipeOfTheDay
selectedCategory = recipe.category
path = [recipe]
}
To maintain people's place in the app, we need:
- Codable
- SceneStorage
To do this:
- Move navigation state into a model type
- Make the navigation model Codable
- Use SceneStorage to save and restore
Starting state from last recipe:
@State private var selectedCategory: Category?
@State private var path: [Recipe] = []
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)
}
}
}
Move navigation state into a model type. Introduce a NavigationModel class that is observable:
class NavigationModel: ObservableObject {
@Published private var selectedCategory: Category?
@Published private var path: [Recipe] = []
}
Then, introduce a state object in the view to hold an instance of the navigation model:
@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)
}
}
}
class NavigationModel: ObservableObject, Codable {
@Published private var selectedCategory: Category?
@Published private 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)
// Decode recipe ID and convert it back into recipes. Use compactMap
// to discard any values that can't be decoded to recipes, as if the recipe is deleted
let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds)
self.path = recipePathIds.compactMap { DataModel.shared[$0] }
// Add a computed property for reading and writing the model as JSON data
var jsonData: Data? { ... }
}
}
We implement a custom conformance to Codable because we don't want to store the entire model value:
- This repeats information that already exists elsewhere
- If the recipe database can change independently, as in Syncing new data, the model could persist stale data
Current state of main view:
@StateObject private var navModel = NavigationModel()
var body: some View {
NavigationSplitView { ... }
}
Introduce SceneStorage to persist model:
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
var body: some View {
NavigationSplitView { ... }
}
Then add a task modifier to view:
@StateObject private var navModel = NavigationModel()
@SceneStorage("navigation") private var data: Data?
var body: some View {
NavigationSplitView { ... }
.task {
if let data = data {
navModel.jsonData = data
}
for await _ in navModel.objectWillChangeSequence {
data = navModel.jsonData
}
}
}
- Adopt the new navigation APIs ASAP
- Old-style programmatic NavigationLink that takes a binding is deprecated in new builds
- Compose
NavigationSplitView
,NavigationStack
, andList
- Put
navigationDestination
modifiers within easy reach (but not inside lazy containers) - Start with
NavigationSplitView
when it makes sense - it automatically adapts to iPhone, and makes it easy to support additional real estate in larger platforms
- SwiftUI on iPad: Organize your interface
- Build a productivity app for Apple Watch
- Bring multiple windows to your SwiftUI app