Skip to content

SwiftUI Sheet, Navigation & Inspector Patterns Reference

SwiftUI Sheet, Navigation & Inspector Patterns Reference

Section titled “SwiftUI Sheet, Navigation & Inspector Patterns Reference”

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

// 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 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.

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 */ } }
}
}
}
}

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

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.

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
}
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

Section titled “Multi-Column Navigation with NavigationSplitView”

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

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")
}
}
}
}
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)
}
}
}
  • 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
macOS Columns 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 / tvOS Collapses into a single stack

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.

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")
}
}
}
}
}
MyEditorView()
.inspector(isPresented: $showInspector) {
InspectorContent()
.inspectorColumnWidth(min: 200, ideal: 250, max: 400)
}
MyEditorView()
.inspector(isPresented: $showInspector) {
InspectorContent()
.inspectorColumnWidth(300)
}
Platform Behavior
macOS Trailing-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.

struct ContentView: View {
@State private var showFullScreen = false
var body: some View {
Button("Show Full Screen") {
showFullScreen = true
}
.fullScreenCover(isPresented: $showFullScreen) {
FullScreenView()
}
}
}
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.

  • 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