Windows and commands

Build a sidebar interface

Use NavigationSplitView to create a Mac-friendly sidebar, selection, and detail layout driven by stable identifiers.

Look at Mail, Notes, or Finder. The Mac pattern is the same everywhere: a sidebar list on the left, the selected item’s detail on the right.

NavigationSplitView gives you that structure with the behavior users expect. A draggable divider, a toolbar button to collapse the sidebar, correct resizing.

On iOS you might reach for NavigationStack and push views. On the Mac, navigation is mostly selection. Nothing gets pushed. The detail area shows whatever is selected. NavigationSplitView models exactly that.

Drive it with an ID

Store the selection as an identifier, not a whole note:

struct ContentView: View {
  @EnvironmentObject private var model: NotesModel
  @State private var selection: Note.ID?

  var body: some View {
    NavigationSplitView {
      List(model.notes, selection: $selection) { note in
        Text(note.title)
      }
      .navigationSplitViewColumnWidth(min: 180, ideal: 220)
    } detail: {
      NoteDetail(id: selection)
    }
  }
}

Because Note is Identifiable, the list tags each row with the note’s ID for you. The detail view receives that ID and asks the model for the current data:

struct NoteDetail: View {
  @EnvironmentObject private var model: NotesModel
  let id: Note.ID?

  var body: some View {
    if let note = model.notes.first(where: { $0.id == id }) {
      Text(note.body)
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    } else {
      Text("Select a note")
        .foregroundStyle(.secondary)
    }
  }
}

Why an ID and not the note?

The model stays the single source of truth. If the selection held a copy of the note, editing it somewhere else would leave the detail showing stale data.

With an ID, the detail always resolves fresh state. And when the note is deleted, the lookup fails cleanly instead of showing a ghost.

Check what you got for free

Run the app. Drag the divider. Click the sidebar toggle in the toolbar. Use the arrow keys in the list. Selection follows the keyboard, which Mac users absolutely expect. You wrote none of that.

Test the empty cases on purpose

Launch with no notes. The detail should show the placeholder, not crash.

Then select a note and delete it. selection now points at a note that no longer exists, first(where:) returns nil, and the placeholder comes back.

Absence is a normal state for selection. Code that assumes “something is always selected” is the most common crash in this layout. Try it on your own project: delete the selected item and watch what the detail pane does.

Lesson completed