List Selection Based Navigation on macOS


When I first developed Cleora for macOS I use the same navigation model as on iOS and iPadOS, NavigationLinks this has its 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 the WWDC21 SwiftUI on the Mac session I noticed that instead of using NavigationLinks they passed a selection binding to the List combined with .tag on the selectable items within the list. Then in the detail used the selection to control what is displayed.


A simple list


Firstly we need to store the current selection somewhere. I find it is best to use the SceneStorage property wrapper so that we get automatic state restoration.


@SceneStorage("com.example.myapp.selection")
var selection: UUID?


The data type you use for identifying your selection will depend on your data model see How to Save Custom Codable Types in SceneStorage.


In simple situations, we can do the following.


struct SideBar: View {
  @Binding
  var selection: UUID?
   
  var body: some View {
    List(selection: $selection) {
      ForEach(items) { item _
        ItemSidebarView(item: item).tag(item.id)
      }
    }.listStyle(.sidebar)
  }
}


Then the main detail area of the app takes selection as well.


struct MainDetailView: View {
  let selection: UUID?
   
  var body: some View {
    if let selection = self.selection {
      ItemDetailView(id: selection)
    } else {
      NothingSelectedView()
    }
  }
}


With the root ContentView holding the SceneStorage selection:


struct ContentView: View {
  @SceneStorage("com.example.myapp.selection")
  var selection: UUID?
   
  var body: some View {
    NavigationView {
      SideBar(selection: $selection)
      MainDetailView(selection: selection)
    }
  }
}


Handling nested items


In macOS it is common to have expandable groups within the sidebar, containing a tree structure of your apps data. For this SwiftUI provides a few different ways to create the tree. There is a constructor for List that lets us pass not only an array of items but also a keyPath to retrieve child items regrettably this does not support programmatically control the expansion of these groups. So I prefer to use use the DisclosureGroups.


When using DisclosureGroup we can pass a binding indicating if the item is expanded. macOS users are used to having multiple expansions active. A Set of expandable ids is perfect for storing this.


@SceneStroage("com.example.myapp.expansion")
var expansion: Set<UUID> = []


List(selection: $selection) {
 ForEach(items) { item _
   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 not 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 the 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 us consider a writing app with a few static navigation targets and a list of articles.


enum Selection {
  case all
  case lastSevenDays
  case trash
  case inbox
  case article(id: UUID)
}


In this case, our selection value is stored as:


@SceneStroage("com.example.myapp.selection")
var selection: Selection?


In the main DetailView we can use a switch statement to select what is display:


switch selection {
  case .all:
    AllArticlesView()
  case .lastSevenDays:
    LastSevenDaysArticlesView()
  case .trash:
    TrashView()
  case .inbox:
    InboxView()
  case .article(let id):
    ArticleView(id: id)
}