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

Check out our book!

Integrating SwiftUI into UIKit Apps

Integrating SwiftUI intoUIKit Apps

UPDATED FOR iOS 17!

A detailed guide on gradually adopting SwiftUI in UIKit projects.

  • Discover various ways to add SwiftUI views to existing UIKit projects
  • Use Xcode previews when designing and building UI
  • Update your UIKit apps with new features such as Swift Charts and Lock Screen widgets
  • Migrate larger parts of your apps to SwiftUI while reusing views and controllers built in UIKit

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