Skip to content

Book 05 Menus And Navigation

Michael Fluharty edited this page May 1, 2026 · 7 revisions

Book 05: Menus And Navigation

Part III — The User Interface · Claude's Xcode 26 Swift Bible

Book-04-Gestures-And-Input · Chapters and Appendices · Book-06-Controls-Buttons-Toggles-Pickers


Live Reference: Inkwell sidebar + LockBox three-column + CryoTunes tabs

Three different production navigation shapes ship across the book's app roster. Inkwell uses a sidebar-and-detail NavigationSplitView for the book's Parts → Books → Chapters → Pages tree (the navigation you're using right now to get to this page). Claudes LockBox uses a three-column NavigationSplitView (Folders → Items → Detail) plus a TabView at the root level (Vault tab + Under the Hood tab). CryoTunes Player uses a flat tab-and-control-cluster pattern fitted to a music player's needs. Reading the three side-by-side shows how the same NavigationStack / NavigationSplitView / TabView primitives compose into very different feeling apps. Sources: Inkwell, LockBox, CryoTunes Player. See Build-Along 00, Build-Along 03, Source Tour 18.


X26 Menu and Toolbar Updates

Menus in X26 pick up the Liquid Glass material on every platform and start showing system icons next to common actions, so a user can pick Cut, Copy, or Paste at a glance instead of reading the words[[mn1]]. The bigger structural change is on iPadOS: every app gets a menu bar now, the same way Mac apps have always had one.[[mn1]]

Adopt Standard Selectors for Menu Item Icons

The way the icons get attached is selector-driven. When a menu item is wired up to a standard selector (Cut, Copy, Paste, and the rest of the platform's standard action vocabulary), the system reads that selector and pairs the right icon with it. Wire your standard actions to the standard selectors and the icons appear; if the action is custom, the selector is custom, and you supply your own icon.[[mn1]]

Related rule worth keeping near this one: whatever you put at the top of a contextual menu should also be the swipe action on the same row.[[mn1]]

Toolbar Grouping with ToolbarSpacer

Toolbars get the new material, and along with it a way to declare which buttons belong in the same cluster. The tool for that is a fixed spacer dropped between groups:[[mn1]]

.toolbar {
    ToolbarItemGroup(placement: .primaryAction) {
        Button("Undo") { undo() }
        Button("Redo") { redo() }
    }
    ToolbarSpacer(.fixed)
    ToolbarItemGroup(placement: .primaryAction) {
        Button("Markup") { markup() }
        Menu("More") { /* ... */ }
    }
}

Buttons inside one group ride on a shared Liquid Glass plate. A ToolbarSpacer(.fixed) between groups gives each group its own plate. The wrong-look version is four unrelated buttons crammed onto a single plate; the right-look version splits by function — undo/redo on one plate, markup/more on another — with the spacer doing the visual separating.[[mn1]]

UIKit and AppKit ship parallel APIs (fixed spacer on UIKit, ToolbarSpacer on AppKit).[[mn1]]

Use Icons for Common Actions; Always Provide Accessibility Labels

For the standard verbs in a toolbar, an icon reads faster than a word and takes less space — reach for icons first.[[mn1]] One rule about mixing them: a single group should be all icons or all text, not a checkerboard of both. And every icon, no matter how obvious you think it is, needs an accessibility label attached. VoiceOver and Voice Control users read the label; sighted users see the icon.[[mn1]]

Hide Toolbar Items Properly

You've probably seen an app with a blank gap in its toolbar — a slot that's clearly there but holds nothing. That's the symptom of hiding the inside of a toolbar item instead of the item. Apply .hidden(_:) at the item level instead of inside it, and the slot collapses with the item; no ghost gap left behind.[[mn1]]

[mn1] Apple Developer Documentation, *Adopting Liquid Glass*. [developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass](https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass) — verified 2026-04-29.


NavigationStack

The modern replacement for NavigationView. Use this for single-column, push-pop navigation on iPhone and iPad.

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Settings", value: "settings")
                NavigationLink("Profile", value: "profile")
            }
            .navigationTitle("Home")
            .navigationDestination(for: String.self) { value in
                DetailView(item: value)
            }
        }
    }
}

Programmatic Navigation with Path

Use NavigationPath when you need to push/pop views from code — after a network call, a button tap, or a deep link.

struct AppView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            VStack(spacing: 20) {
                Button("Go to Step 1") {
                    path.append("step1")
                }
                Button("Jump to Step 3") {
                    path.append("step1")
                    path.append("step2")
                    path.append("step3")
                }
            }
            .navigationTitle("Start")
            .navigationDestination(for: String.self) { step in
                StepView(step: step, path: $path)
            }
        }
    }
}

struct StepView: View {
    let step: String
    @Binding var path: NavigationPath

    var body: some View {
        VStack {
            Text("Current: \(step)")
            Button("Back to Root") {
                path = NavigationPath() // clears entire stack
            }
        }
        .navigationTitle(step)
    }
}

Typed Navigation Path

When all your destinations share a single type, skip NavigationPath and use a plain array.

struct RecipeApp: View {
    @State private var path: [Recipe] = []

    var body: some View {
        NavigationStack(path: $path) {
            RecipeListView()
                .navigationDestination(for: Recipe.self) { recipe in
                    RecipeDetailView(recipe: recipe)
                }
        }
    }
}

Watch Out

  • NavigationPath is type-erased. It can hold any Hashable type, but you need a .navigationDestination(for:) registered for each type you append.
  • If you append a value with no matching destination, the push silently fails. No crash, no error. Just nothing happens.
  • Do not nest NavigationStack inside another NavigationStack. You get double navigation bars and broken behavior.

NavigationSplitView

For two- or three-column layouts. iPad and Mac get real columns; iPhone collapses to a stack automatically.

Two-Column

struct TwoColumnView: View {
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            List(items, selection: $selectedItem) { item in
                Text(item.name)
            }
            .navigationTitle("Items")
        } detail: {
            if let selectedItem {
                ItemDetailView(item: selectedItem)
            } else {
                ContentUnavailableView("Select an Item",
                    systemImage: "tray",
                    description: Text("Pick something from the sidebar."))
            }
        }
    }
}

Three-Column

NavigationSplitView {
    // Sidebar (column 1)
    CategoryListView(selection: $selectedCategory)
} content: {
    // Content (column 2)
    if let selectedCategory {
        ItemListView(category: selectedCategory, selection: $selectedItem)
    }
} detail: {
    // Detail (column 3)
    if let selectedItem {
        ItemDetailView(item: selectedItem)
    }
}

Controlling Column Visibility

@State private var columnVisibility: NavigationSplitViewVisibility = .all

NavigationSplitView(columnVisibility: $columnVisibility) {
    Sidebar()
} detail: {
    Detail()
}

Options: .all, .doubleColumn, .detailOnly, .automatic.

Watch Out

  • On iPhone, NavigationSplitView collapses into a single navigation stack. The sidebar becomes the root list. This is automatic but test it — your layout assumptions may not hold.
  • selection binding on List inside NavigationSplitView drives navigation. If you also use NavigationLink(value:), you can get conflicts. Pick one approach.

NavigationLink

Two forms exist: the modern value-based form and the older view-based form.

Value-Based (Preferred)

NavigationLink("Show Detail", value: myItem)

Pair with .navigationDestination(for:) on a parent. The destination is declared once, not per link.

View-Based (Legacy but Functional)

NavigationLink("Show Detail") {
    DetailView(item: myItem)
}

This still works. The downside is that the destination view is created at the same time as the link, even if the user never taps it. For heavy views, that wastes memory.

Custom Label

NavigationLink(value: recipe) {
    HStack {
        Image(systemName: "fork.knife")
        VStack(alignment: .leading) {
            Text(recipe.name).font(.headline)
            Text(recipe.cuisine).font(.caption).foregroundStyle(.secondary)
        }
    }
}

Navigation Title

.navigationTitle("Recipes")             // standard
.navigationTitle($editableTitle)         // editable title (iOS 16+)

Display Modes (iOS)

.navigationBarTitleDisplayMode(.large)     // big title, scrolls to inline
.navigationBarTitleDisplayMode(.inline)    // small centered title
.navigationBarTitleDisplayMode(.automatic) // inherits from parent

Watch Out

  • .navigationTitle goes on the content inside the NavigationStack, not on the NavigationStack itself. Put it on the List or VStack, not the outer container.

TabView

Basic Tabs

struct MainView: View {
    @State private var selectedTab = 0

    var body: some View {
        TabView(selection: $selectedTab) {
            Tab("Home", systemImage: "house", value: 0) {
                HomeView()
            }
            Tab("Search", systemImage: "magnifyingglass", value: 1) {
                SearchView()
            }
            Tab("Settings", systemImage: "gear", value: 2) {
                SettingsView()
            }
        }
    }
}

Badge

Tab("Inbox", systemImage: "tray", value: 0) {
    InboxView()
}
.badge(unreadCount)

Watch Out

  • Each tab should contain its own NavigationStack if it needs navigation. Do not wrap the entire TabView in a NavigationStack.
  • Tab state resets when switching tabs unless you preserve it with @State or @SceneStorage.

macOS Menu Bar

CommandGroup: Adding to Existing Menus

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandGroup(replacing: .newItem) {
                Button("New Recipe") {
                    // action
                }
                .keyboardShortcut("n", modifiers: .command)
            }

            CommandGroup(after: .sidebar) {
                Button("Toggle Inspector") {
                    // action
                }
                .keyboardShortcut("i", modifiers: [.command, .option])
            }
        }
    }
}

Common placements: .newItem, .saveItem, .sidebar, .toolbar, .help, .pasteboard, .undoRedo.

CommandMenu: Custom Top-Level Menu

.commands {
    CommandMenu("Recipes") {
        Button("Import from File...") { importRecipes() }
            .keyboardShortcut("i", modifiers: [.command, .shift])

        Divider()

        Button("Export All...") { exportRecipes() }

        Menu("Sort By") {
            Button("Name") { sortBy(.name) }
            Button("Date Added") { sortBy(.date) }
            Button("Rating") { sortBy(.rating) }
        }
    }
}

Watch Out

  • Menu commands cannot directly access view state. Use @FocusedValue or @FocusedBinding to bridge between the menu bar and the focused view.
  • .commands modifier goes on the Scene, not on a View.

FocusedValue Bridge

// 1. Define the key
struct FocusedRecipeKey: FocusedValueKey {
    typealias Value = Binding<Recipe>
}

extension FocusedValues {
    var selectedRecipe: Binding<Recipe>? {
        get { self[FocusedRecipeKey.self] }
        set { self[FocusedRecipeKey.self] = newValue }
    }
}

// 2. Publish from the view
struct RecipeDetailView: View {
    @Binding var recipe: Recipe

    var body: some View {
        Text(recipe.name)
            .focusedSceneValue(\.selectedRecipe, $recipe)
    }
}

// 3. Consume in the menu command
struct MyApp: App {
    @FocusedBinding(\.selectedRecipe) var focusedRecipe

    var body: some Scene {
        WindowGroup { ContentView() }
        .commands {
            CommandMenu("Recipe") {
                Button("Mark as Favorite") {
                    focusedRecipe?.isFavorite = true
                }
                .disabled(focusedRecipe == nil)
            }
        }
    }
}

Context Menus

Text("Hold me")
    .contextMenu {
        Button("Copy", action: copyItem)
        Button("Delete", role: .destructive, action: deleteItem)

        Divider()

        Menu("Share") {
            Button("Messages", action: shareViaMessages)
            Button("Mail", action: shareViaMail)
        }
    }

Context Menu with Preview

Text(recipe.name)
    .contextMenu {
        Button("Edit") { editRecipe() }
        Button("Delete", role: .destructive) { deleteRecipe() }
    } preview: {
        RecipePreviewCard(recipe: recipe)
            .frame(width: 300, height: 200)
    }

Watch Out

  • Context menus only support Button, Divider, Menu, Toggle, and Picker. No arbitrary views — no sliders, no text fields.
  • On macOS, context menus trigger on right-click. On iOS, long press.

Practical Tips

  1. Start with NavigationStack. Only move to NavigationSplitView when you actually need columns (iPad/Mac sidebar layouts).

  2. Use value-based NavigationLink with .navigationDestination(for:). It separates the "what to show" from the "where it lives" and enables programmatic navigation.

  3. Pop to root by resetting the path: path = NavigationPath() or path.removeLast(path.count).

  4. Deep linking: Build your NavigationPath from URL components on app launch, then set it as the initial path.

  5. Test on all platforms. NavigationSplitView behaves very differently on iPhone vs iPad vs Mac. The compiler will not catch layout surprises.

  6. Keep NavigationStack out of reusable components. The view that owns the NavigationStack should be a top-level coordinator, not a leaf view.


Book-04-Gestures-And-Input · Chapters and Appendices · Book-06-Controls-Buttons-Toggles-Pickers

Feedback: Found something off? Open an issue · Discuss it · Email Michael

Clone this wiki locally