MacKuba

🍎 Kuba Suder's blog on Mac & iOS development

WWDC 22

The SwiftUI cookbook for navigation

Categories: SwiftUI 0 comments Watch the video

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. 1. It appends the assigned value of Recipe type into the path
  2. 2. The navigation stack then checks the navigationDestination configuration which stores a mapping of Recipe -> some View to get an appropriate view to present
  3. 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. 1. The first column displays a list like in the three-pane version
  2. 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



Leave a comment

*

*
This will only be used to display your Gravatar image.

*

What's the name of the base class of all AppKit and UIKit classes?

*