.agents/skills/swiftui-expert-skill/references/sheet-navigation-patterns.md
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)
}
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)
}
}
}
NavigationSplitView(columnVisibility: $visibility) with NavigationSplitViewVisibility (.detailOnly, .doubleColumn, .all).navigationSplitViewColumnWidth(min:ideal:max:) on each columnNavigationSplitView(preferredCompactColumn: $column) to control which column shows on narrow devices.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
InspectorCommandsin your app's.commandsto 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.
.sheet(item:) for model-based sheetsNavigationStack with navigationDestination(for:) for type-safe navigationNavigationPath for programmatic navigationNavigationSplitView for sidebar-driven multi-column layoutsInspector for trailing-edge supplementary panelsnavigationSplitViewColumnWidth(min:ideal:max:) or inspectorColumnWidth(min:ideal:max:)Identifiable type with .sheet(item:) when presenting multiple sheets