Navigation Basics for SwiftUI on macOS

Starting with Xcode 14.1, you'll be able to implement the new NavigationSplitView API which handles navigation using multiple columns on a macOS app. This article will show you how to get up and running with NavigationSplitView and how to manage the sidebar, content, and details of your data.

For simplicity, this article will be working with basic views with the following format:

struct UsersView: View {
    var body: some View {
        List(DDUser.data) { user in
            Text(user.fullName)
        }
    }
}
struct UserDetailsView: View {
    let user: DDUser
    var body: some View {
        Text("Hello \(user.id)")
    }
}
struct AnimalsView: View { ... }
struct AnimalDetailsView: View { ... }
struct FoodView: View { ... }
struct FoodDetailsView: View { ... }

You can see the example project here. It's using SwiftDummyData to provide the models which are prefixed with DD.

Your app will need a View that is responsible for handling which Views can be shown and when to show them. Create a new file called NavigationManagerView.swift and add the following code:

import SwiftUI

struct NavigationManagerView: View {
    // 1
    @State var sideBarVisibility: NavigationSplitViewVisibility = .doubleColumn
    
    var body: some View {
        // 2
        NavigationSplitView(columnVisibility: $sideBarVisibility) {
            Text("Users")
        // 3
        } detail: {
            UsersView()
        }
    }
}
  1. sideBarVisibility is the property that will be used to handle how many columns will be visible at a time. The .doubleColumn value specifies that at least two columns should be shown initially.
  2. NavigationSplityView is the new View that makes navigation much easier for macOS, and it's compatible across the other Apple platforms as well. The first block contains the SideBar column's content, which will be some text that says "Users"
  3. The details block is responsible for showing the contents of the second column.

Update your app object to show NavigationManagerView as the initial view:

... // WindowGroup {

NavigationManagerView()

... // }

If you build and run, your macOS app should look like the picture below:

macOS initial build

In many apps, the SideBar contains different sections/features of the app and is limited in selection. To list the different options available from the SideBar, create an enum to represent each case:

enum SideBarItem: String, Identifiable, CaseIterable {
    var id: String { rawValue }
    
    case users
    case animals
    case food
}

SideBarItem will conform to String to have a simple rawValue, Identifiable to be used in a List, and CaseIterable to provide the .allCases property.

Create an instance property of SideBarItem in NavigationManagerView:

... // @State var sideBarVisibility: NavigationSplitViewVisibility = .doubleColumn

 @State var selectedSideBarItem: SideBarItem = .users
 
 ... // var body: some View {

Then update the NavigationSplitView to the following:

NavigationSplitView(columnVisibility: $sideBarVisibility) {
    // 1
    List(SideBarItem.allCases, selection: $selectedSideBarItem) { item in
        // 2
        NavigationLink(
            item.rawValue.localizedCapitalized,
            value: item
        )
    }
} detail: {
    // 3
    switch selectedSideBarItem {
    case .users:
        UsersView()
    case .animals:
        AnimalsView()
    case .food:
        FoodView()
    }
}
  1. List is used to iterate over the different cases for SideBarItem and the selection parameter will keep the selected side bar item highlighted.
  2. NavigationLink makes each option in the SideBar clickable and works with List to update the value for selectedSideBarItem.
  3. The details block is updated to switch over the possible side bar items and presents its respective View.

You will now be able to see the data for each section when you select it in the SideBar.

Side Bar Implemented

To enable the user to click an item from one of the tabs and see the details about the selected item, we have to make a slight modification to each of the views that contain a List.

Update UsersView to the following code and make similar adjustments to AnimalsView and FoodView:

struct UsersView: View {
    // 1
    let selectedUser: Binding<DDUser?>
    
    var body: some View {
        // 2
        List(DDUser.data, selection: selectedUser) { user in
            // 3
            NavigationLink(user.id, value: user)
        }
    }
}
  1. selectedUser is a binding property that will link the parent view (NavigationManagerView) to the selected value from the List.
  2. List has been updated with the selected property to keep the clicked item highlighted and work with the NavigationLink below it.
  3. NavigationLink is used in place of Text to make the user.id clickable and pass the value to the hosting NavigationSplitView.

Next, create an enum that will represent selecting each of the items from the listing views:

enum DetailItem: Hashable {
    case user(DDUser)
    case animal(DDAnimal)
    case food(DDFood)
}

Each case has an associated value that is required to initialize each of the detail views for the respective model.

Back in NavigationManagerView add the following properties:

... // @State var selectedSideBarItem: SideBarItem = .users

// 1
@State var selectedUser: DDUser?
@State var selectedAnimal: DDAnimal?
@State var selectedFood: DDFood?

// 2
var selectedDetailItem: DetailItem? {
    if let user = selectedUser {
        return .user(user)
    } else if let animal = selectedAnimal {
        return .animal(animal)
    } else if let food = selectedFood {
        return .food(food)
    } else {
        return nil
    }
}

... // var body: some View {
  1. These properties will store the selected values from the listing views.
  2. selectedDetailsItem is a computed property that will surface the selected DetailItem based on the properties being used as bindings.

Since selectedDetailsItem is dependent on each of the selected- properties, it's important to clear the selected item each time selectedSideBarItem is updated. Add the following modifier to the List in the SideBar:

... // 

.onChange(of: selectedSideBarItem) { _ in
    self.selectedUser = nil
    self.selectedFood = nil
    self.selectedAnimal = nil
}

... // } detail: {

Now the selected items will be set to nil when moving between SideBarItems.

Finally, replace the details block with the following code:

// 1
} content: {
    switch selectedSideBarItem {
    case .users:
        UsersView(selectedUser: $selectedUser)
    case .animals:
        AnimalsView(selectedAnimal: $selectedAnimal)
    case .food:
        FoodView(selectedFood: $selectedFood)
    }
// 2
} detail: {
    // 3
    if let detailItem = selectedDetailItem {
        switch detailItem {
        case .user(let user):
            UserDetailsView(user: user)
        case .animal(let animal):
            AnimalDetailsView(animal: animal)
        case .food(let food):
            FoodDetailsView(food: food)
        }
    }
}
  1. The original detail block was updated to content since we are now working with a three column layout.
  2. The new detail column will be responsible for displaying the object details screen.
  3. If the selectedDetailItem has a value, display the propert details view by passing the associated value.

Build and run and you should now be able to select any of the SideBarItems and click a list item to see the details for each:

Details item being displayed

Here is the final code of NavigationManagerView:

struct NavigationManagerView: View {
    @State var sideBarVisibility: NavigationSplitViewVisibility = .doubleColumn
    @State var selectedSideBarItem: SideBarItem = .users
    
    @State var selectedUser: DDUser?
    @State var selectedAnimal: DDAnimal?
    @State var selectedFood: DDFood?
    
    var selectedDetailItem: DetailItem? {
        if let user = selectedUser {
            return .user(user)
        } else if let animal = selectedAnimal {
            return .animal(animal)
        } else if let food = selectedFood {
            return .food(food)
        } else {
            return nil
        }
    }
    
    var body: some View {
        NavigationSplitView(columnVisibility: $sideBarVisibility) {
            List(SideBarItem.allCases, selection: $selectedSideBarItem) { item in
                NavigationLink(
                    item.rawValue.localizedCapitalized,
                    value: item
                )
            }
            .onChange(of: selectedSideBarItem) { _ in
                self.selectedUser = nil
                self.selectedFood = nil
                self.selectedAnimal = nil
            }
        } content: {
            switch selectedSideBarItem {
            case .users:
                UsersView(selectedUser: $selectedUser)
            case .animals:
                AnimalsView(selectedAnimal: $selectedAnimal)
            case .food:
                FoodView(selectedFood: $selectedFood)
            }
        } detail: {
            if let detailItem = selectedDetailItem {
                switch detailItem {
                case .user(let user):
                    UserDetailsView(user: user)
                case .animal(let animal):
                    AnimalDetailsView(animal: animal)
                case .food(let food):
                    FoodDetailsView(food: food)
                }
            }
        }
    }
}

The entire project can be found here.

Share on Twitter