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()
}
}
}
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.NavigationSplityView
is the newView
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"- 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:
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()
}
}
List
is used to iterate over the different cases forSideBarItem
and theselection
parameter will keep the selected side bar item highlighted.NavigationLink
makes each option in the SideBar clickable and works withList
to update the value forselectedSideBarItem
.- The
details
block is updated to switch over the possible side bar items and presents its respectiveView
.
You will now be able to see the data for each section when you select it in the SideBar.
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)
}
}
}
selectedUser
is a binding property that will link the parent view (NavigationManagerView
) to the selected value from theList
.List
has been updated with theselected
property to keep the clicked item highlighted and work with theNavigationLink
below it.NavigationLink
is used in place ofText
to make theuser.id
clickable and pass the value to the hostingNavigationSplitView
.
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 {
- These properties will store the selected values from the listing views.
selectedDetailsItem
is a computed property that will surface the selectedDetailItem
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)
}
}
}
- The original
detail
block was updated tocontent
since we are now working with a three column layout. - The new
detail
column will be responsible for displaying the object details screen. - 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:
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.