Selection based navigation in SwiftUI macOS apps
When I started making macOS apps in SwiftUI I was using the same navigation model as on iOS and iPadOS that relied on NavigationLink. But this system has some drawbacks on macOS. When users hide the sidebar, we are unable to programmatically alter navigation. Whenever the user changed navigation SwiftUI considered the detail view a new view structure even when it was just a data change.
Watching SwiftUI on the Mac session from WWDC 2021 I noticed that instead of using navigation links, they passed a selection
binding to the List
view combined with tag
on the selectable items within the list. The detail view then used the selection
to control what is displayed.
In this post I'm going to share how we can use selection-based navigation for our SwiftUI macOS apps.
Here is how a simple List
with selection could look. The data type you use for identifying selection will depend on your data model.
struct SideBar: View {
@Binding var selection: UUID?
var body: some View {
List(selection: $selection) {
ForEach(items) { item in
ItemSidebarView(item: item)
.tag(item.id)
}
}
.listStyle(.sidebar)
}
}
Then the main detail area of the app will need to get the current selection
as well.
struct MainDetailView: View {
let selection: UUID?
var body: some View {
if let selection = self.selection {
ItemDetailView(id: selection)
} else {
NothingSelectedView()
}
}
}
Our ContentView
will be holding the selection. I find it is best to use the SceneStorage property wrapper so that we can get automatic state restoration.
struct ContentView: View {
@SceneStorage("com.example.myapp.selection")
var selection: UUID?
var body: some View {
NavigationView {
SideBar(selection: $selection)
MainDetailView(selection: selection)
}
}
}
# Nested list data
In macOS it is common to have expandable groups within the sidebar containing a tree structure of your app's data. SwiftUI provides a few different ways to create the tree.
There is a constructor for List
that lets us pass a keyPath
to retrieve child items, but it doesn't support programmatic control of the expansion of these groups. I prefer to use DisclosureGroup instead.
When using a DisclosureGroup
, we can pass it a binding indicating if the item is expanded. A Set
of ids is perfect for storing the expansion of multiple sections.
@SceneStroage("com.example.myapp.expansion")
var expansion: Set<UUID> = []
List(selection: $selection) {
ForEach(items) { item in
DisclosureGroup(
isExpanded: $expansion.for(value: item.id)
) {
... // list children here
} label: {
ItemSidebarView(item: item)
}
.tag(item.id)
}
}
Notice that the tag
is placed on DisclosureGroup
directly and not on its label
.
We also need to declare an extension on Binding
that lets us create a Binding<Bool>
from our Binding<Set<UUID>>
type.
extension Binding where Value == Set<UUID> {
func for(value: UUID) -> Binding<Bool> {
Binding<Bool> {
self.wrappedValue.contains(value)
} set: { newValue in
if newValue {
self.wrappedValue.insert(value)
} else {
self.wrappedValue.remove(value)
}
}
}
}
# Different object types in sidebar navigation
Having more than one object type that can be selected in the sidebar is common in macOS apps. We need to make a modification to the data type that is stored in SceneStorage
to accommodate this.
Let's consider a writing app with a few static navigation targets and a list of articles. Here is what the selection type for such app could be.
enum Selection {
case all
case lastSevenDays
case trash
case inbox
case article(id: UUID)
}
Inside our main detail view we can use a switch
statement to select what to display based on the current selection.
switch selection {
case .all:
AllArticlesView()
case .lastSevenDays:
LastSevenDaysArticlesView()
case .trash:
TrashView()
case .inbox:
InboxView()
case .article(let id):
ArticleView(id: id)
}