-
Notifications
You must be signed in to change notification settings - Fork 0
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 →
Three different production navigation shapes ship across the book's app roster. Inkwell uses a sidebar-and-detail
NavigationSplitViewfor 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-columnNavigationSplitView(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 sameNavigationStack/NavigationSplitView/TabViewprimitives compose into very different feeling apps. Sources: Inkwell, LockBox, CryoTunes Player. See Build-Along 00, Build-Along 03, Source Tour 18.
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]]
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]]
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]]
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]]
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.
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)
}
}
}
}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)
}
}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)
}
}
}
}-
NavigationPathis type-erased. It can hold anyHashabletype, 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
NavigationStackinside anotherNavigationStack. You get double navigation bars and broken behavior.
For two- or three-column layouts. iPad and Mac get real columns; iPhone collapses to a stack automatically.
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."))
}
}
}
}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)
}
}@State private var columnVisibility: NavigationSplitViewVisibility = .all
NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar()
} detail: {
Detail()
}Options: .all, .doubleColumn, .detailOnly, .automatic.
- On iPhone,
NavigationSplitViewcollapses into a single navigation stack. The sidebar becomes the root list. This is automatic but test it — your layout assumptions may not hold. -
selectionbinding onListinsideNavigationSplitViewdrives navigation. If you also useNavigationLink(value:), you can get conflicts. Pick one approach.
Two forms exist: the modern value-based form and the older view-based form.
NavigationLink("Show Detail", value: myItem)Pair with .navigationDestination(for:) on a parent. The destination is declared once, not per link.
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.
NavigationLink(value: recipe) {
HStack {
Image(systemName: "fork.knife")
VStack(alignment: .leading) {
Text(recipe.name).font(.headline)
Text(recipe.cuisine).font(.caption).foregroundStyle(.secondary)
}
}
}.navigationTitle("Recipes") // standard
.navigationTitle($editableTitle) // editable title (iOS 16+).navigationBarTitleDisplayMode(.large) // big title, scrolls to inline
.navigationBarTitleDisplayMode(.inline) // small centered title
.navigationBarTitleDisplayMode(.automatic) // inherits from parent-
.navigationTitlegoes on the content inside the NavigationStack, not on the NavigationStack itself. Put it on theListorVStack, not the outer container.
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()
}
}
}
}Tab("Inbox", systemImage: "tray", value: 0) {
InboxView()
}
.badge(unreadCount)- Each tab should contain its own
NavigationStackif it needs navigation. Do not wrap the entireTabViewin aNavigationStack. - Tab state resets when switching tabs unless you preserve it with
@Stateor@SceneStorage.
@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.
.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) }
}
}
}- Menu commands cannot directly access view state. Use
@FocusedValueor@FocusedBindingto bridge between the menu bar and the focused view. -
.commandsmodifier goes on theScene, not on aView.
// 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)
}
}
}
}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)
}
}Text(recipe.name)
.contextMenu {
Button("Edit") { editRecipe() }
Button("Delete", role: .destructive) { deleteRecipe() }
} preview: {
RecipePreviewCard(recipe: recipe)
.frame(width: 300, height: 200)
}- Context menus only support
Button,Divider,Menu,Toggle, andPicker. No arbitrary views — no sliders, no text fields. - On macOS, context menus trigger on right-click. On iOS, long press.
-
Start with NavigationStack. Only move to NavigationSplitView when you actually need columns (iPad/Mac sidebar layouts).
-
Use value-based NavigationLink with
.navigationDestination(for:). It separates the "what to show" from the "where it lives" and enables programmatic navigation. -
Pop to root by resetting the path:
path = NavigationPath()orpath.removeLast(path.count). -
Deep linking: Build your
NavigationPathfrom URL components on app launch, then set it as the initial path. -
Test on all platforms. NavigationSplitView behaves very differently on iPhone vs iPad vs Mac. The compiler will not catch layout surprises.
-
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
Claude's X26 Swift6 Bible | GPL v3 | Built with Claude by Anthropic | Repo