Back to Provenance

SwiftUI Sheet, Navigation & Inspector Patterns Reference

.agents/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md

3.3.110.6 KB
Original Source

SwiftUI Sheet, Navigation & Inspector Patterns Reference

Table of Contents

Sheet Patterns

Item-Driven Sheets (Preferred)

Use .sheet(item:) instead of .sheet(isPresented:) when presenting model-based content.

swift
// Good - item-driven
@State private var selectedItem: Item?

var body: some View {
    List(items) { item in
        Button(item.name) {
            selectedItem = item
        }
    }
    .sheet(item: $selectedItem) { item in
        ItemDetailSheet(item: item)
    }
}

// Avoid - boolean flag requires separate state
@State private var showSheet = false
@State private var selectedItem: Item?

var body: some View {
    List(items) { item in
        Button(item.name) {
            selectedItem = item
            showSheet = true
        }
    }
    .sheet(isPresented: $showSheet) {
        if let selectedItem {
            ItemDetailSheet(item: selectedItem)
        }
    }
}

Why: .sheet(item:) automatically handles presentation state and avoids optional unwrapping in the sheet body.

Sheets Own Their Actions

Sheets should handle their own dismiss and actions internally using @Environment(\.dismiss). Avoid passing onSave/onCancel closures from the parent -- it creates callback prop-drilling and reduces reusability.

swift
struct EditItemSheet: View {
    @Environment(\.dismiss) private var dismiss
    let item: Item
    @State private var name: String

    init(item: Item) {
        self.item = item
        _name = State(initialValue: item.name)
    }

    var body: some View {
        NavigationStack {
            Form { TextField("Name", text: $name) }
                .navigationTitle("Edit Item")
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
                    ToolbarItem(placement: .confirmationAction) { Button("Save") { /* save and dismiss */ } }
                }
        }
    }
}

Enum-Based Sheet Management

When presenting multiple different sheets, use an Identifiable enum with .sheet(item:) instead of multiple boolean state properties:

swift
struct ArticlesView: View {
    enum Sheet: Identifiable {
        case add, edit(Article), categories
        var id: String {
            switch self {
            case .add: "add"
            case .edit(let a): "edit-\(a.id)"
            case .categories: "categories"
            }
        }
    }

    @State private var presentedSheet: Sheet?

    var body: some View {
        List { /* ... */ }
            .toolbar {
                Button("Add") { presentedSheet = .add }
            }
            .sheet(item: $presentedSheet) { sheet in
                switch sheet {
                case .add: AddArticleView()
                case .edit(let article): EditArticleView(article: article)
                case .categories: CategoriesView()
                }
            }
    }
}

Why: A single @State property and one .sheet(item:) modifier replaces N boolean properties and N sheet modifiers, improving readability and preventing only-one-sheet-at-a-time conflicts.

Type-Safe Navigation with NavigationStack

swift
struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink("Profile", value: Route.profile)
                NavigationLink("Settings", value: Route.settings)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .profile:
                    ProfileView()
                case .settings:
                    SettingsView()
                }
            }
        }
    }
}

enum Route: Hashable {
    case profile
    case settings
}

Programmatic Navigation

swift
struct ContentView: View {
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            List {
                Button("Go to Detail") {
                    navigationPath.append(DetailRoute.item(id: 1))
                }
            }
            .navigationDestination(for: DetailRoute.self) { route in
                switch route {
                case .item(let id):
                    ItemDetailView(id: id)
                }
            }
        }
    }
}

enum DetailRoute: Hashable {
    case item(id: Int)
}

Multi-Column Navigation with NavigationSplitView

Two-Column Layout

Use NavigationSplitView for sidebar-driven navigation. Available on iOS 16+, macOS 13+, tvOS 16+, watchOS 9+.

swift
struct ContentView: View {
    @State private var selectedItem: Item.ID?

    var body: some View {
        NavigationSplitView {
            List(items, selection: $selectedItem) { item in
                Text(item.name)
            }
            .navigationTitle("Items")
        } detail: {
            if let selectedItem, let item = items.first(where: { $0.id == selectedItem }) {
                ItemDetailView(item: item)
            } else {
                ContentUnavailableView("Select an Item", systemImage: "doc")
            }
        }
    }
}

Three-Column Layout

swift
struct ContentView: View {
    @State private var departmentId: Department.ID?
    @State private var employeeIds = Set<Employee.ID>()

    var body: some View {
        NavigationSplitView {
            List(model.departments, selection: $departmentId) { dept in
                Text(dept.name)
            }
        } content: {
            if let department = model.department(id: departmentId) {
                List(department.employees, selection: $employeeIds) { emp in
                    Text(emp.name)
                }
            } else {
                Text("Select a department")
            }
        } detail: {
            EmployeeDetails(for: employeeIds)
        }
    }
}

Configuration

  • Column visibility: NavigationSplitView(columnVisibility: $visibility) with NavigationSplitViewVisibility (.detailOnly, .doubleColumn, .all)
  • Column widths: .navigationSplitViewColumnWidth(min:ideal:max:) on each column
  • Compact column: NavigationSplitView(preferredCompactColumn: $column) to control which column shows on narrow devices
  • Style: .navigationSplitViewStyle(.balanced) or .prominentDetail (default)

Platform Behavior

PlatformBehavior
macOSColumns always visible side-by-side; sidebar has translucent material; variable-width column resizing by dragging
iPadOS (regular)Sidebar can overlay or push detail; supports column visibility toggle via toolbar button
iOS / iPadOS (compact)Collapses into a single NavigationStack; sidebar items show disclosure chevrons; back button navigates between columns
iPhone (all sizes)Always collapsed into a stack; sidebar appears as the root list; selections push detail onto the stack
watchOS / tvOSCollapses into a single stack

Inspector

Availability: iOS 17.0+, macOS 14.0+

A trailing-edge panel for supplementary information.

On wider size classes (macOS, iPad landscape), it appears as a trailing column. On compact size classes (iPhone), it adapts to a sheet automatically.

Basic Inspector

swift
struct ShapeEditor: View {
    @State private var showInspector = false

    var body: some View {
        MyEditorView()
            .inspector(isPresented: $showInspector) {
                InspectorContent()
            }
            .toolbar {
                ToolbarItem {
                    Button {
                        showInspector.toggle()
                    } label: {
                        Label("Inspector", systemImage: "info.circle")
                    }
                }
            }
    }
}

Inspector with Column Width

swift
MyEditorView()
    .inspector(isPresented: $showInspector) {
        InspectorContent()
            .inspectorColumnWidth(min: 200, ideal: 250, max: 400)
    }

Inspector with Fixed Width

swift
MyEditorView()
    .inspector(isPresented: $showInspector) {
        InspectorContent()
            .inspectorColumnWidth(300)
    }

Platform Behavior

PlatformBehavior
macOSTrailing-edge sidebar panel; resizable by dragging edge; integrates with window toolbar
iPadOS (regular)Trailing column alongside content; toggleable via toolbar button
iOS / iPadOS (compact)Adapts to a sheet presentation; swipe-to-dismiss supported
iPhone (all sizes)Always presented as a sheet (no trailing column); dismiss via swipe or button

Tip: Use InspectorCommands in your app's .commands to include the default inspector toggle keyboard shortcut.

Presentation Modifiers

Full Screen Cover

swift
struct ContentView: View {
    @State private var showFullScreen = false
    
    var body: some View {
        Button("Show Full Screen") {
            showFullScreen = true
        }
        .fullScreenCover(isPresented: $showFullScreen) {
            FullScreenView()
        }
    }
}

Popover

swift
struct ContentView: View {
    @State private var showPopover = false
    
    var body: some View {
        Button("Show Popover") {
            showPopover = true
        }
        .popover(isPresented: $showPopover) {
            PopoverContentView()
                .presentationCompactAdaptation(.popover)  // Don't adapt to sheet on iPhone
        }
    }
}

For alert and confirmationDialog API patterns, see latest-apis.md.

Summary Checklist

  • Use .sheet(item:) for model-based sheets
  • Sheets own their actions and dismiss internally
  • Use NavigationStack with navigationDestination(for:) for type-safe navigation
  • Use NavigationPath for programmatic navigation
  • Use NavigationSplitView for sidebar-driven multi-column layouts
  • Use Inspector for trailing-edge supplementary panels
  • Set column widths with navigationSplitViewColumnWidth(min:ideal:max:) or inspectorColumnWidth(min:ideal:max:)
  • Use appropriate presentation modifiers (sheet, fullScreenCover, popover)
  • Alerts and confirmation dialogs use modern API with actions
  • Avoid passing dismiss/save callbacks to sheets
  • Use enum-based Identifiable type with .sheet(item:) when presenting multiple sheets
  • Navigation state can be saved/restored when needed