NEW BOOK! Swift Gems: 100+ tips to take your Swift code to the next level. Learn more ...NEW BOOK! Swift Gems:100+ advanced Swift tips. Learn more...

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)
            }
        }
    }
}
Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Check out our new book!

Swift Gems

100+ tips to take your Swift code to the next level

Swift Gems

100+ tips to take your Swift code to the next level

  • Advanced Swift techniques for experienced developers bypassing basic tutorials
  • Curated, actionable tips ready for immediate integration into any Swift project
  • Strategies to improve code quality, structure, and performance across all platforms
  • Practical Swift insights from years of development, applicable from iOS to server-side Swift

# 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)
}