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

Level up your Swift skills!$35

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

Swift Gemsby Natalia Panferova

  • 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

Level up your Swift skills!

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

Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Swift Gems

by Natalia Panferova

$35

# 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)
}
Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Enhance older apps with SwiftUI!$45

A detailed guide on gradually adopting SwiftUI in UIKit projects

Updated for iOS 18 and Xcode 16!

Integrating SwiftUI into UIKit Appsby Natalia Panferova

  • Upgrade your apps with new features like Swift Charts and Widgets
  • Support older iOS versions with effective backward-compatible strategies
  • Seamlessly bridge state and data between UIKit and SwiftUI using the latest APIs

Enhance older apps with SwiftUI!

A detailed guide on gradually adopting SwiftUI in UIKit projects

Integrating SwiftUI into UIKit Apps by Natalia Panferova book coverIntegrating SwiftUI into UIKit Apps by Natalia Panferova book cover

Integrating SwiftUI
into UIKit Apps

by Natalia Panferova

Updated for iOS 18 and Xcode 16!

$45