
Guide Swiftui Ui Patterns
- 221 installs
- 297 repo stars
- Updated August 4, 2026
- vabole/apple-skills
Apply proven SwiftUI layout, navigation, state, and component patterns to ship consistent, accessible iOS and macOS interfaces faster.
About
Guide to SwiftUI UI patterns for Apple apps: composable views, navigation, observable state, adaptive layouts, lists, sheets, and accessibility. Helps mobile developers build polished, HIG-aligned interfaces with less bespoke layout code and fewer SwiftUI pitfalls.
- Composable view architecture
- Navigation and state patterns
- Adaptive layout for size classes
- Accessibility and Dynamic Type
- Reusable component recipes
Guide Swiftui Ui Patterns by the numbers
- 221 all-time installs (skills.sh)
- +2 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #917 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vabole/apple-skills --skill guide-swiftui-ui-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 221 |
|---|---|
| repo stars | ★ 297 |
| Last updated | August 4, 2026 |
| Repository | vabole/apple-skills ↗ |
What it does
Apply proven SwiftUI layout, navigation, state, and component patterns to ship consistent, accessible iOS and macOS interfaces faster.
Files
Guide Skill — This is an expert workflow/pattern guide, not API reference documentation.
Originally from Dimillian/Skills by Thomas Ricouard. MIT License.
SwiftUI UI Patterns
Quick start
Choose a track based on your goal:
Existing project
- Identify the feature or screen and the primary interaction model (list, detail, editor, settings, tabbed).
- Find a nearby example in the repo with
rg "TabView\("or similar, then read the closest SwiftUI view. - Apply local conventions: prefer SwiftUI-native state, keep state local when possible, and use environment injection for shared dependencies.
- Choose the relevant component reference from
references/components-index.mdand follow its guidance. - If the interaction reveals secondary content by dragging or scrolling the primary content away, read
references/scroll-reveal.mdbefore implementing gestures manually. - Build the view with small, focused subviews and SwiftUI-native data flow.
New project scaffolding
- Start with
references/app-wiring.mdto wire TabView + NavigationStack + sheets. - Add a minimal
AppTabandRouterPathbased on the provided skeletons. - Choose the next component reference based on the UI you need first (TabView, NavigationStack, Sheets).
- Expand the route and sheet enums as new screens are added.
General rules to follow
- Use modern SwiftUI state (
@State,@Binding,@Observable,@Environment) and avoid unnecessary view models. - If the deployment target includes iOS 16 or earlier and cannot use the Observation API introduced in iOS 17, fall back to
ObservableObjectwith@StateObjectfor root ownership,@ObservedObjectfor injected observation, and@EnvironmentObjectonly for truly shared app-level state. - Prefer composition; keep views small and focused.
- Use async/await with
.taskand explicit loading/error states. For restart, cancellation, and debouncing guidance, readreferences/async-state.md. - Keep shared app services in
@Environment, but prefer explicit initializer injection for feature-local dependencies and models. For root wiring patterns, readreferences/app-wiring.md. - Prefer the newest SwiftUI API that fits the deployment target and call out the minimum OS whenever a pattern depends on it.
- Maintain existing legacy patterns only when editing legacy files.
- Follow the project's formatter and style guide.
- Sheets: Prefer
.sheet(item:)over.sheet(isPresented:)when state represents a selected model. Avoidif letinside a sheet body. Sheets should own their actions and calldismiss()internally instead of forwardingonCancel/onConfirmclosures. - Scroll-driven reveals: Prefer deriving a normalized progress value from scroll offset and driving the visual state from that single source of truth. Avoid parallel gesture state machines unless scroll alone cannot express the interaction.
State ownership summary
Use the narrowest state tool that matches the ownership model:
| Scenario | Preferred pattern |
|---|---|
| Local UI state owned by one view | @State |
| Child mutates parent-owned value state | @Binding |
| Root-owned reference model on iOS 17+ | @State with an @Observable type |
Child reads or mutates an injected @Observable model on iOS 17+ | Pass it explicitly as a stored property |
| Shared app service or configuration | @Environment(Type.self) |
| Legacy reference model on iOS 16 and earlier | @StateObject at the root, @ObservedObject when injected |
Choose the ownership location first, then pick the wrapper. Do not introduce a reference model when plain value state is enough.
Cross-cutting references
references/navigationstack.md: navigation ownership, per-tab history, and enum routing.references/sheets.md: centralized modal presentation and enum-driven sheets.references/deeplinks.md: URL handling and routing external links into app destinations.references/app-wiring.md: root dependency graph, environment usage, and app shell wiring.references/async-state.md:.task,.task(id:), cancellation, debouncing, and async UI state.references/previews.md:#Preview, fixtures, mock environments, and isolated preview setup.references/performance.md: stable identity, observation scope, lazy containers, and render-cost guardrails.
Anti-patterns
- Giant views that mix layout, business logic, networking, routing, and formatting in one file.
- Multiple boolean flags for mutually exclusive sheets, alerts, or navigation destinations.
- Live service calls directly inside
body-driven code paths instead of view lifecycle hooks or injected models/services. - Reaching for
AnyViewto work around type mismatches that should be solved with better composition. - Defaulting every shared dependency to
@EnvironmentObjector a global router without a clear ownership reason.
Workflow for a new SwiftUI view
1. Define the view's state, ownership location, and minimum OS assumptions before writing UI code. 2. Identify which dependencies belong in @Environment and which should stay as explicit initializer inputs. 3. Sketch the view hierarchy, routing model, and presentation points; extract repeated parts into subviews. For complex navigation, read references/navigationstack.md, references/sheets.md, or references/deeplinks.md. Build and verify no compiler errors before proceeding. 4. Implement async loading with .task or .task(id:), plus explicit loading and error states when needed. Read references/async-state.md when the work depends on changing inputs or cancellation. 5. Add previews for the primary and secondary states, then add accessibility labels or identifiers when the UI is interactive. Read references/previews.md when the view needs fixtures or injected mock dependencies. 6. Validate with a build: confirm no compiler errors, check that previews render without crashing, ensure state changes propagate correctly, and sanity-check that list identity and observation scope will not cause avoidable re-renders. Read references/performance.md if the screen is large, scroll-heavy, or frequently updated. For common SwiftUI compilation errors — missing @State annotations, ambiguous ViewBuilder closures, or mismatched generic types — resolve them before updating callsites. If the build fails: read the error message carefully, fix the identified issue, then rebuild before proceeding to the next step. If a preview crashes, isolate the offending subview, confirm its state initialisation is valid, and re-run the preview before continuing.
Component references
Use references/components-index.md as the entry point. Each component reference should include:
- Intent and best-fit scenarios.
- Minimal usage pattern with local conventions.
- Pitfalls and performance notes.
- Paths to existing examples in the current repo.
Adding a new component reference
- Create
references/<component>.md. - Keep it short and actionable; link to concrete files in the current repo.
- Update
references/components-index.mdwith the new entry.
SwiftUI Accessibility Patterns
Originally from AvdLee/SwiftUI-Agent-Skill by Antoine van der Lee and Omar Elsayed. MIT License.
Core Principle
Prefer Button over onTapGesture for tappable elements. Button provides VoiceOver support, focus handling, and proper traits for free.
Dynamic Type and @ScaledMetric
System text styles scale with Dynamic Type automatically. Prefer built-in styles like .largeTitle, .title, .title2, .title3, .headline, .subheadline, .body, .callout, .footnote, .caption, and .caption2 when they fit your UI:
VStack(alignment: .leading) {
Text("Inbox")
.font(.title2)
Text("3 unread messages")
.font(.body)
Text("Updated just now")
.font(.caption)
}For custom fonts, use a Dynamic Type-aware font initializer so the text still follows the user's preferred content size:
VStack(alignment: .leading) {
Text("Article")
.font(.custom("SourceSerif4-Semibold", size: 28, relativeTo: .title2))
Text("Body copy")
.font(.custom("SourceSerif4-Regular", size: 17))
}Font.custom(_:size:relativeTo:) lets you match a specific text style. Font.custom(_:size:) scales relative to the body style. Avoid fixed-size custom fonts for primary content that should respond to Dynamic Type.
For non-text numeric values like padding, spacing, and image sizes, use @ScaledMetric:
struct ProfileHeader: View {
@ScaledMetric private var avatarSize = 60.0
@ScaledMetric private var spacing = 12.0
var body: some View {
HStack(spacing: spacing) {
Image("avatar")
.resizable()
.frame(width: avatarSize, height: avatarSize)
Text("Username")
}
}
}Specify a relativeTo text style when the value should track a specific Dynamic Type style, including for images or icons that should stay proportional to nearby text:
struct StatusRow: View {
@ScaledMetric(relativeTo: .body) private var iconSize = 18.0
var body: some View {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: iconSize))
Text("Synced")
.font(.custom("AvenirNext-Regular", size: 17, relativeTo: .body))
}
}
}Accessibility Traits
Use accessibilityAddTraits and accessibilityRemoveTraits for state-driven traits:
Text(item.title)
.accessibilityAddTraits(item.isSelected ? [.isSelected, .isButton] : .isButton)Use .disabled(true) to make VoiceOver announce "Dimmed" for non-interactive elements.
Decorative Images
Use Image(decorative:bundle:) when an asset image is purely visual and should not appear in the accessibility tree.
Image(decorative: "confetti")This is appropriate for backgrounds, flourishes, and icons that do not add meaning beyond nearby text.
If the image conveys information, keep it accessible and provide a clear label:
Image("receipt")
.accessibilityLabel("Receipt")For non-asset images, such as SF Symbols, hide decorative content with accessibilityHidden(true) instead:
Image(systemName: "sparkles")
.accessibilityHidden(true)Element Grouping
.combine -- Auto-join child labels
HStack {
Image(systemName: "star.fill")
Text("Favorites")
Text("(\(count))")
}
.accessibilityElement(children: .combine)VoiceOver reads all child labels as one element, separated by commas.
.ignore -- Manual label for container
HStack {
Text(item.name)
Spacer()
Text(item.price)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.name), \(item.price)").contain -- Semantic grouping
HStack {
ForEach(tabs) { tab in
TabButton(tab: tab)
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel("Tab bar")VoiceOver announces the container name when focus enters/exits.
Custom Controls
Adjustable controls (increment/decrement)
PageControl(selectedIndex: $selectedIndex, pageCount: pageCount)
.accessibilityElement()
.accessibilityValue("Page \(selectedIndex + 1) of \(pageCount)")
.accessibilityAdjustableAction { direction in
switch direction {
case .increment:
guard selectedIndex < pageCount - 1 else { break }
selectedIndex += 1
case .decrement:
guard selectedIndex > 0 else { break }
selectedIndex -= 1
@unknown default:
break
}
}Representing custom views as native controls
When a custom view should behave like a native control for accessibility:
HStack {
Text(label)
Toggle("", isOn: $isOn)
}
.accessibilityRepresentation {
Toggle(label, isOn: $isOn)
}Label-content pairing
@Namespace private var ns
HStack {
Text("Volume")
.accessibilityLabeledPair(role: .label, id: "volume", in: ns)
Slider(value: $volume)
.accessibilityLabeledPair(role: .content, id: "volume", in: ns)
}Summary Checklist
- [ ] Use
Buttoninstead ofonTapGesturefor tappable elements - [ ] Use built-in text styles or Dynamic Type-aware custom fonts for text
- [ ] Use
@ScaledMetricfor custom values that should scale with Dynamic Type - [ ] Mark purely decorative images as decorative or hidden from accessibility
- [ ] Group related elements with
accessibilityElement(children:) - [ ] Provide
accessibilityLabelwhen default labels are unclear - [ ] Use
accessibilityRepresentationfor custom controls - [ ] Use
accessibilityAdjustableActionfor increment/decrement controls - [ ] Ensure navigation flow is logical when using VoiceOver grouping
App wiring and dependency graph
Intent
Show how to wire the app shell (TabView + NavigationStack + sheets) and install a global dependency graph (environment objects, services, streaming clients, SwiftData ModelContainer) in one place.
Recommended structure
1) Root view sets up tabs, per-tab routers, and sheets. 2) A dedicated view modifier installs global dependencies and lifecycle tasks (auth state, streaming watchers, push tokens, data containers). 3) Feature views pull only what they need from the environment; feature-specific state stays local.
Dependency selection
- Use
@Environmentfor app-level services, shared clients, theme/configuration, and values that many descendants genuinely need. - Prefer initializer injection for feature-local dependencies and models. Do not move a dependency into the environment just to avoid passing one or two arguments.
- Keep mutable feature state out of the environment unless it is intentionally shared across broad parts of the app.
- Use
@EnvironmentObjectonly as a legacy fallback or when the project already standardizes on it for a truly shared object.
Root shell example (generic)
@MainActor
struct AppView: View {
@State private var selectedTab: AppTab = .home
@State private var tabRouter = TabRouter()
var body: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
let router = tabRouter.router(for: tab)
NavigationStack(path: tabRouter.binding(for: tab)) {
tab.makeContentView()
}
.withSheetDestinations(sheet: Binding(
get: { router.presentedSheet },
set: { router.presentedSheet = $0 }
))
.environment(router)
.tabItem { tab.label }
.tag(tab)
}
}
.withAppDependencyGraph()
}
}Minimal AppTab example:
@MainActor
enum AppTab: Identifiable, Hashable, CaseIterable {
case home, notifications, settings
var id: String { String(describing: self) }
@ViewBuilder
func makeContentView() -> some View {
switch self {
case .home: HomeView()
case .notifications: NotificationsView()
case .settings: SettingsView()
}
}
@ViewBuilder
var label: some View {
switch self {
case .home: Label("Home", systemImage: "house")
case .notifications: Label("Notifications", systemImage: "bell")
case .settings: Label("Settings", systemImage: "gear")
}
}
}Router skeleton:
@MainActor
@Observable
final class RouterPath {
var path: [Route] = []
var presentedSheet: SheetDestination?
}
enum Route: Hashable {
case detail(id: String)
}Dependency graph modifier (generic)
Use a single modifier to install environment objects and handle lifecycle hooks when the active account/client changes. This keeps wiring consistent and avoids forgetting a dependency in call sites.
extension View {
func withAppDependencyGraph(
accountManager: AccountManager = .shared,
currentAccount: CurrentAccount = .shared,
currentInstance: CurrentInstance = .shared,
userPreferences: UserPreferences = .shared,
theme: Theme = .shared,
watcher: StreamWatcher = .shared,
pushNotifications: PushNotificationsService = .shared,
intentService: AppIntentService = .shared,
quickLook: QuickLook = .shared,
toastCenter: ToastCenter = .shared,
namespace: Namespace.ID? = nil,
isSupporter: Bool = false
) -> some View {
environment(accountManager)
.environment(accountManager.currentClient)
.environment(quickLook)
.environment(currentAccount)
.environment(currentInstance)
.environment(userPreferences)
.environment(theme)
.environment(watcher)
.environment(pushNotifications)
.environment(intentService)
.environment(toastCenter)
.environment(\.isSupporter, isSupporter)
.task(id: accountManager.currentClient.id) {
let client = accountManager.currentClient
if let namespace { quickLook.namespace = namespace }
currentAccount.setClient(client: client)
currentInstance.setClient(client: client)
userPreferences.setClient(client: client)
await currentInstance.fetchCurrentInstance()
watcher.setClient(client: client, instanceStreamingURL: currentInstance.instance?.streamingURL)
if client.isAuth {
watcher.watch(streams: [.user, .direct])
} else {
watcher.stopWatching()
}
}
.task(id: accountManager.pushAccounts.map(\.token)) {
pushNotifications.tokens = accountManager.pushAccounts.map(\.token)
}
}
}Notes:
- The
.task(id:)hooks respond to account/client changes, re-seeding services and watcher state. - Keep the modifier focused on global wiring; feature-specific state stays within features.
- Adjust types (AccountManager, StreamWatcher, etc.) to match your project.
SwiftData / ModelContainer
Install your ModelContainer at the root so all feature views share the same store. Keep the list minimal to the models that need persistence.
extension View {
func withModelContainer() -> some View {
modelContainer(for: [Draft.self, LocalTimeline.self, TagGroup.self])
}
}Why: a single container avoids duplicated stores per sheet or tab and keeps data consistent.
Sheet routing (enum-driven)
Centralize sheets with a small enum and a helper modifier.
enum SheetDestination: Identifiable {
case composer
case settings
var id: String { String(describing: self) }
}
extension View {
func withSheetDestinations(sheet: Binding<SheetDestination?>) -> some View {
sheet(item: sheet) { destination in
switch destination {
case .composer:
ComposerView().withEnvironments()
case .settings:
SettingsView().withEnvironments()
}
}
}
}Why: enum-driven sheets keep presentation centralized and testable; adding a new sheet means adding one enum case and one switch branch.
When to use
- Apps with multiple packages/modules that share environment objects and services.
- Apps that need to react to account/client changes and rewire streaming/push safely.
- Any app that wants consistent TabView + NavigationStack + sheet wiring without repeating environment setup.
Caveats
- Keep the dependency modifier slim; do not put feature state or heavy logic there.
- Ensure
.task(id:)work is lightweight or cancelled appropriately; long-running work belongs in services. - If unauthenticated clients exist, gate streaming/watch calls to avoid reconnect spam.
Async state and task lifecycle
Intent
Use this pattern when a view loads data, reacts to changing input, or coordinates async work that should follow the SwiftUI view lifecycle.
Core rules
- Use
.taskfor load-on-appear work that belongs to the view lifecycle. - Use
.task(id:)when async work should restart for a changing input such as a query, selection, or identifier. - Treat cancellation as a normal path for view-driven tasks. Check
Task.isCancelledin longer flows and avoid surfacing cancellation as a user-facing error. - Debounce or coalesce user-driven async work such as search before it fans out into repeated requests.
- Keep UI-facing models and mutations main-actor-safe; do background work in services, then publish the result back to UI state.
Example: load on appear
struct DetailView: View {
let id: String
@State private var state: LoadState<Item> = .idle
@Environment(ItemClient.self) private var client
var body: some View {
content
.task {
await load()
}
}
@ViewBuilder
private var content: some View {
switch state {
case .idle, .loading:
ProgressView()
case .loaded(let item):
ItemContent(item: item)
case .failed(let error):
ErrorView(error: error)
}
}
private func load() async {
state = .loading
do {
state = .loaded(try await client.fetch(id: id))
} catch is CancellationError {
return
} catch {
state = .failed(error)
}
}
}Example: restart on input change
struct SearchView: View {
@State private var query = ""
@State private var results: [ResultItem] = []
@Environment(SearchClient.self) private var client
var body: some View {
List(results) { item in
Text(item.title)
}
.searchable(text: $query)
.task(id: query) {
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled, !query.isEmpty else {
results = []
return
}
do {
results = try await client.search(query)
} catch is CancellationError {
return
} catch {
results = []
}
}
}
}When to move work out of the view
- If the async flow spans multiple screens or must survive view dismissal, move it into a service or model.
- If the view is mostly coordinating app-level lifecycle or account changes, wire it at the app shell in
app-wiring.md. - If retry, caching, or offline policy becomes complex, keep the policy in the client/service and leave the view with simple state transitions.
Pitfalls
- Do not start network work directly from
body. - Do not ignore cancellation for searches, typeahead, or rapidly changing selections.
- Avoid storing derived async state in multiple places when one source of truth is enough.
Components Index
Use this file to find component and cross-cutting guidance. Each entry lists when to use it.
Available components
- TabView:
references/tabview.md— Use when building a tab-based app or any tabbed feature set. - NavigationStack:
references/navigationstack.md— Use when you need push navigation and programmatic routing, especially per-tab history. - Sheets and presentation:
references/sheets.md— Use for local item-driven sheets, centralized modal routing, and sheet-specific action patterns. - Form and Settings:
references/form.md— Use for settings, grouped inputs, and structured data entry. - macOS Settings:
references/macos-settings.md— Use when building a macOS Settings window with SwiftUI's Settings scene. - Split views and columns:
references/split-views.md— Use for iPad/macOS multi-column layouts or custom secondary columns. - List and Section:
references/list.md— Use for feed-style content and settings rows. - ScrollView and Lazy stacks:
references/scrollview.md— Use for custom layouts, horizontal scrollers, or grids. - Scroll-reveal detail surfaces:
references/scroll-reveal.md— Use when a detail screen reveals secondary content or actions as the user scrolls or swipes between full-screen sections. - Grids:
references/grids.md— Use for icon pickers, media galleries, and tiled layouts. - Theming and dynamic type:
references/theming.md— Use for app-wide theme tokens, colors, and type scaling. - Controls (toggles, pickers, sliders):
references/controls.md— Use for settings controls and input selection. - Input toolbar (bottom anchored):
references/input-toolbar.md— Use for chat/composer screens with a sticky input bar. - Top bar overlays (iOS 26+ and fallback):
references/top-bar.md— Use for pinned selectors or pills above scroll content. - Overlay and toasts:
references/overlay.md— Use for transient UI like banners or toasts. - Focus handling:
references/focus.md— Use for@FocusState, field chaining, focusable views, focused values for commands, default focus, search focus, and common pitfalls. - Scroll patterns:
references/scroll-patterns.md— Use for programmatic scrolling, scroll position tracking, paging, snap-to-item, parallax, and scroll-based effects. - Searchable:
references/searchable.md— Use for native search UI with scopes and async results. - Async images and media:
references/media.md— Use for remote media, previews, and media viewers. - Haptics:
references/haptics.md— Use for tactile feedback tied to key actions. - Matched transitions:
references/matched-transitions.md— Use for smooth source-to-destination animations. - Deep links and URL routing:
references/deeplinks.md— Use for in-app navigation from URLs. - Title menus:
references/title-menus.md— Use for filter or context menus in the navigation title. - Menu bar commands:
references/menu-bar.md— Use when adding or customizing macOS/iPadOS menu bar commands. - Loading & placeholders:
references/loading-placeholders.md— Use for redacted skeletons, empty states, and loading UX. - Lightweight clients:
references/lightweight-clients.md— Use for small, closure-based API clients injected into stores. - Accessibility patterns:
references/accessibility.md— Use for Dynamic Type, @ScaledMetric, accessibility traits, element grouping, decorative images, and custom accessible controls.
- State management:
references/state-management.md— Use for@State,@Binding,@Bindable,@Observable,@Environment,@ObservationIgnored, and data flow patterns. - Sheet and navigation patterns:
references/sheet-navigation-patterns.md— Use for item-driven sheets, enum-based sheet management,NavigationSplitView, Inspector, and presentation modifiers. - Layout best practices:
references/layout-best-practices.md— Use for relative layout, context-agnostic views, GeometryReader alternatives, and layout performance. - Image optimization:
references/image-optimization.md— Use for AsyncImage, downsampling, caching, and SF Symbols. - Deprecated API replacements:
references/latest-apis.md— Use to find the modern replacement for any deprecated SwiftUI API.
Cross-cutting references
- App wiring and dependency graph:
references/app-wiring.md— Use to wire the app shell, install shared dependencies, and decide what belongs in the environment. - Async state and task lifecycle:
references/async-state.md— Use when a view loads data, reacts to changing input, or needs cancellation/debouncing guidance. - Previews:
references/previews.md— Use when adding#Preview, fixtures, mock environments, or isolated preview setup. - Performance guardrails:
references/performance.md— Use when a screen is large, scroll-heavy, frequently updated, or showing signs of avoidable re-renders.
Planned components (create files as needed)
- Web content: create
references/webview.md— Use for embedded web content or in-app browsing. - Status composer patterns: create
references/composer.md— Use for composition or editor workflows. - Text input and validation: create
references/text-input.md— Use for forms, validation, and text-heavy input. - Design system usage: create
references/design-system.md— Use when applying shared styling rules.
Adding entries
- Add the component file and link it here with a short “when to use” description.
- Keep each component reference short and actionable.
Controls (Toggle, Slider, Picker)
Intent
Use native controls for settings and configuration screens, keeping labels accessible and state bindings clear.
Core patterns
- Bind controls directly to
@State,@Binding, or@AppStorage. - Prefer
Togglefor boolean preferences. - Use
Sliderfor numeric ranges and show the current value in a label. - Use
Pickerfor discrete choices; use.pickerStyle(.segmented)only for 2–4 options. - Keep labels visible and descriptive; avoid embedding buttons inside controls.
Example: toggles with sections
Form {
Section("Notifications") {
Toggle("Mentions", isOn: $preferences.notificationsMentionsEnabled)
Toggle("Follows", isOn: $preferences.notificationsFollowsEnabled)
Toggle("Boosts", isOn: $preferences.notificationsBoostsEnabled)
}
}Example: slider with value text
Section("Font Size") {
Slider(value: $fontSizeScale, in: 0.5...1.5, step: 0.1)
Text("Scale: \(String(format: \"%.1f\", fontSizeScale))")
.font(.scaledBody)
}Example: picker for enums
Picker("Default Visibility", selection: $visibility) {
ForEach(Visibility.allCases, id: \.self) { option in
Text(option.title).tag(option)
}
}Design choices to keep
- Group related controls in a
Formsection. - Use
.disabled(...)to reflect locked or inherited settings. - Use
Labelinside toggles to combine icon + text when it adds clarity.
Pitfalls
- Avoid
.pickerStyle(.segmented)for large sets; use menu or inline styles instead. - Don’t hide labels for sliders; always show context.
- Avoid hard-coding colors for controls; use theme tint sparingly.
Deep links and navigation
Intent
Route external URLs into in-app destinations while falling back to system handling when needed.
Core patterns
- Centralize URL handling in the router (
handle(url:),handleDeepLink(url:)). - Inject an
OpenURLActionhandler that delegates to the router. - Use
.onOpenURLfor app scheme links and convert them to web URLs if needed. - Let the router decide whether to navigate or open externally.
Example: router entry points
@MainActor
final class RouterPath {
var path: [Route] = []
var urlHandler: ((URL) -> OpenURLAction.Result)?
func handle(url: URL) -> OpenURLAction.Result {
if isInternal(url) {
navigate(to: .status(id: url.lastPathComponent))
return .handled
}
return urlHandler?(url) ?? .systemAction
}
func handleDeepLink(url: URL) -> OpenURLAction.Result {
// Resolve federated URLs, then navigate.
navigate(to: .status(id: url.lastPathComponent))
return .handled
}
}Example: attach to a root view
extension View {
func withLinkRouter(_ router: RouterPath) -> some View {
self
.environment(
\.openURL,
OpenURLAction { url in
router.handle(url: url)
}
)
.onOpenURL { url in
router.handleDeepLink(url: url)
}
}
}Design choices to keep
- Keep URL parsing and decision logic inside the router.
- Avoid handling deep links in multiple places; one entry point is enough.
- Always provide a fallback to
OpenURLActionorUIApplication.shared.open.
Pitfalls
- Don’t assume the URL is internal; validate first.
- Avoid blocking UI while resolving remote links; use
Task.
SwiftUI Focus Patterns
Originally from AvdLee/SwiftUI-Agent-Skill by Antoine van der Lee and Omar Elsayed. MIT License.
@FocusState
Always mark @FocusState as private. Use Bool for a single field, an optional Hashable enum for multiple fields.
Single field
@FocusState private var isFocused: Bool
TextField("Email", text: $email)
.focused($isFocused)Multiple fields
enum Field: Hashable { case name, email, password }
@FocusState private var focusedField: Field?
TextField("Name", text: $name)
.focused($focusedField, equals: .name)
TextField("Email", text: $email)
.focused($focusedField, equals: .email)Set focusedField = .email to move focus programmatically; set nil to dismiss the keyboard.
Field chaining with .onSubmit
struct EditTagView: View {
enum FocusField { case title, symbol, newTag }
@FocusState private var focusedField: FocusField?
var body: some View {
Form {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
.onSubmit { focusedField = .symbol }
TextField("Symbol", text: $symbol)
.focused($focusedField, equals: .symbol)
.onSubmit { focusedField = .newTag }
}
.defaultFocus($focusedField, .title)
}
}focused(_:) vs focused(_:equals:) with nested views
.focused($bool) reports true when the modified view or any focusable descendant has focus. .focused($enum, equals:) reports its value only when that specific view receives focus.
enum Focus: Hashable { case container, field }
@FocusState private var focus: Focus?
VStack {
TextField("Name", text: $name)
.focused($focus, equals: .field)
}
.focusable()
.focused($focus, equals: .container)isFocused environment value
Read-only environment value that returns true when the nearest focusable ancestor has focus. Useful for styling non-focusable child views.
struct HighlightWrapperModifier: ViewModifier {
@Environment(\.isFocused) private var isFocused
func body(content: Content) -> some View {
content
.background(isFocused ? Color.accentColor.opacity(0.1) : .clear)
}
}Making Views Focusable
.focusable(_:)
Makes a non-text-input view participate in the focus system. Focused views can respond to keyboard events via onKeyPress and menu commands like Edit > Delete via onDeleteCommand.
struct SelectableCard: View {
@FocusState private var isFocused: Bool
var body: some View {
CardContent()
.focusable()
.focused($isFocused)
.border(isFocused ? Color.accentColor : .clear)
.onDeleteCommand { deleteCard() }
}
}.focusable(_:interactions:)
Controls which focus-driven interactions the view supports via FocusInteractions:
.activate-- Button-like: only focusable when system-wide keyboard navigation is on.edit-- Captures keyboard/Digital Crown input.automatic-- Platform default (both activate and edit)
MyTapGestureView(...)
.focusable(interactions: .activate)Focused Values for Commands and Menus
Focused values let parent views (App, Scene, Commands) read state from whichever view currently has focus. Use for enabling/disabling menu commands based on the focused document or selection.
Declare with @Entry
extension FocusedValues {
@Entry var selectedDocument: Binding<Document>?
}Publish from views
// View-scoped: available when this view (or descendant) has focus
.focusedValue(\.selectedDocument, $document)
// Scene-scoped: available when this scene has focus
.focusedSceneValue(\.selectedDocument, $document)Consume in commands
@FocusedValue reads the value; @FocusedBinding unwraps a Binding automatically.
@main
struct MyApp: App {
@FocusedBinding(\.selectedDocument) var document
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandGroup(after: .pasteboard) {
Button("Duplicate") { document?.duplicate() }
.disabled(document == nil)
}
}
}
}Default Focus
Prefer .defaultFocus over setting @FocusState in .onAppear for initial focus placement.
@FocusState private var focusedField: Field?
VStack {
TextField("Name", text: $name)
.focused($focusedField, equals: .name)
TextField("Email", text: $email)
.focused($focusedField, equals: .email)
}
.defaultFocus($focusedField, .email)Priority: .automatic (default) applies on window appearance and programmatic focus changes. .userInitiated also applies during user-driven focus navigation.
Focus Scope and Sections
.focusScope(_:) (macOS/tvOS/watchOS)
Limits default focus preferences to a namespace. Use with prefersDefaultFocus and resetFocus.
.focusSection() (macOS/tvOS)
Guides directional and sequential focus movement through a group of focusable descendants. Useful when focusable views are spatially separated and directional navigation would otherwise skip them.
HStack {
VStack { Button("1") {}; Button("2") {}; Spacer() }
Spacer()
VStack { Spacer(); Button("A") {}; Button("B") {} }
.focusSection()
}resetFocus environment action (macOS/tvOS/watchOS)
Re-evaluates default focus within a namespace.
@Namespace var scopeID
@Environment(\.resetFocus) private var resetFocus
Button("Reset") { resetFocus(in: scopeID) }Focus Effects
.focusEffectDisabled(_:)
Suppresses the system focus ring (macOS) or hover effect. Use when providing custom focus visuals.
MyCustomCard()
.focusable()
.focusEffectDisabled()
.overlay { customFocusRing }Search Focus
.searchFocused(_:) / .searchFocused(_:equals:)
Bind focus state to the search field associated with the nearest .searchable modifier.
@FocusState private var isSearchFocused: Bool
NavigationStack {
ContentView()
.searchable(text: $query)
.searchFocused($isSearchFocused)
}
// Programmatically focus the search bar
Button("Search") { isSearchFocused = true }Common Pitfalls
Redundant @FocusState writes revoke focus
.focusable() + .focused() handles focus-on-click natively. Adding a tap gesture that also writes to @FocusState triggers a redundant state write, causing a second body evaluation that revokes focus.
// WRONG -- tap gesture redundantly sets focus
CardView()
.focusable()
.focused($isFocused)
.onTapGesture { isFocused = true } // Remove this line
// CORRECT -- let .focusable() + .focused() handle it
CardView()
.focusable()
.focused($isFocused)Ambiguous focus bindings
Binding the same enum case to multiple views is ambiguous. Always use distinct enum cases for each focusable view.
.onAppear focus timing
Setting @FocusState in .onAppear may fail if the view tree hasn't settled. Prefer .defaultFocus for reliable initial focus. If you must use .onAppear, wrap in DispatchQueue.main.async as a last resort.
Missing .focusable() for non-text views
TextField and SecureField are implicitly focusable. Custom views (stacks, shapes, images) are not. Forgetting .focusable() means .focused() bindings have no effect and key event handlers never fire.
Design choices
- Keep focus state local to the view that owns the fields.
- Use focus changes to drive UX (validation messages, helper UI).
- Pair with
.scrollDismissesKeyboard(...)when using ScrollView/Form. - Don't store focus state in shared objects; it is view-local.
Form
Intent
Use Form for structured settings, grouped inputs, and action rows. This pattern keeps layout, spacing, and accessibility consistent for data entry screens.
Core patterns
- Wrap the form in a
NavigationStackonly when it is presented in a sheet or standalone view without an existing navigation context. - Group related controls into
Sectionblocks. - Use
.scrollContentBackground(.hidden)plus a custom background color when you need design-system colors. - Apply
.formStyle(.grouped)for grouped styling when appropriate. - Use
@FocusStateto manage keyboard focus in input-heavy forms.
Example: settings-style form
@MainActor
struct SettingsView: View {
@Environment(Theme.self) private var theme
var body: some View {
NavigationStack {
Form {
Section("General") {
NavigationLink("Display") { DisplaySettingsView() }
NavigationLink("Haptics") { HapticsSettingsView() }
}
Section("Account") {
Button("Edit profile") { /* open sheet */ }
.buttonStyle(.plain)
}
.listRowBackground(theme.primaryBackgroundColor)
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
}
}
}Example: modal form with validation
@MainActor
struct AddRemoteServerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(Theme.self) private var theme
@State private var server: String = ""
@State private var isValid = false
@FocusState private var isServerFieldFocused: Bool
var body: some View {
NavigationStack {
Form {
TextField("Server URL", text: $server)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($isServerFieldFocused)
.listRowBackground(theme.primaryBackgroundColor)
Button("Add") {
guard isValid else { return }
dismiss()
}
.disabled(!isValid)
.listRowBackground(theme.primaryBackgroundColor)
}
.formStyle(.grouped)
.navigationTitle("Add Server")
.navigationBarTitleDisplayMode(.inline)
.scrollContentBackground(.hidden)
.background(theme.secondaryBackgroundColor)
.scrollDismissesKeyboard(.immediately)
.toolbar { CancelToolbarItem() }
.onAppear { isServerFieldFocused = true }
}
}
}Design choices to keep
- Prefer
Formover custom stacks for settings and input screens. - Keep rows tappable by using
.contentShape(Rectangle())and.buttonStyle(.plain)on row buttons. - Use list row backgrounds to keep section styling consistent with your theme.
Pitfalls
- Avoid heavy custom layouts inside a
Form; it can lead to spacing issues. - If you need highly custom layouts, prefer
ScrollView+VStack. - Don’t mix multiple background strategies; pick either default Form styling or custom colors.
Grids
Intent
Use LazyVGrid for icon pickers, media galleries, and dense visual selections where items align in columns.
Core patterns
- Use
.adaptivecolumns for layouts that should scale across device sizes. - Use multiple
.flexiblecolumns when you want a fixed column count. - Keep spacing consistent and small to avoid uneven gutters.
- Use
GeometryReaderinside grid cells when you need square thumbnails.
Example: adaptive icon grid
let columns = [GridItem(.adaptive(minimum: 120, maximum: 1024))]
LazyVGrid(columns: columns, spacing: 6) {
ForEach(icons) { icon in
Button {
select(icon)
} label: {
ZStack(alignment: .bottomTrailing) {
Image(icon.previewName)
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(6)
if icon.isSelected {
Image(systemName: "checkmark.seal.fill")
.padding(4)
.tint(.green)
}
}
}
.buttonStyle(.plain)
}
}Example: fixed 3-column media grid
LazyVGrid(
columns: [
.init(.flexible(minimum: 100), spacing: 4),
.init(.flexible(minimum: 100), spacing: 4),
.init(.flexible(minimum: 100), spacing: 4),
],
spacing: 4
) {
ForEach(items) { item in
GeometryReader { proxy in
ThumbnailView(item: item)
.frame(width: proxy.size.width, height: proxy.size.width)
}
.aspectRatio(1, contentMode: .fit)
}
}Design choices to keep
- Use
LazyVGridfor large collections; avoid non-lazy grids for big sets. - Keep tap targets full-bleed using
.contentShape(Rectangle())when needed. - Prefer adaptive grids for settings pickers and flexible layouts.
Pitfalls
- Avoid heavy overlays in every grid cell; it can be expensive.
- Don’t nest grids inside other grids without a clear reason.
Haptics
Intent
Use haptics sparingly to reinforce user actions (tab selection, refresh, success/error) and respect user preferences.
Core patterns
- Centralize haptic triggers in a
HapticManageror similar utility. - Gate haptics behind user preferences and hardware support.
- Use distinct types for different UX moments (selection vs. notification vs. refresh).
Example: simple haptic manager
@MainActor
final class HapticManager {
static let shared = HapticManager()
enum HapticType {
case buttonPress
case tabSelection
case dataRefresh(intensity: CGFloat)
case notification(UINotificationFeedbackGenerator.FeedbackType)
}
private let selectionGenerator = UISelectionFeedbackGenerator()
private let impactGenerator = UIImpactFeedbackGenerator(style: .heavy)
private let notificationGenerator = UINotificationFeedbackGenerator()
private init() { selectionGenerator.prepare() }
func fire(_ type: HapticType, isEnabled: Bool) {
guard isEnabled else { return }
switch type {
case .buttonPress:
impactGenerator.impactOccurred()
case .tabSelection:
selectionGenerator.selectionChanged()
case let .dataRefresh(intensity):
impactGenerator.impactOccurred(intensity: intensity)
case let .notification(style):
notificationGenerator.notificationOccurred(style)
}
}
}Example: usage
Button("Save") {
HapticManager.shared.fire(.notification(.success), isEnabled: preferences.hapticsEnabled)
}
TabView(selection: $selectedTab) { /* tabs */ }
.onChange(of: selectedTab) { _, _ in
HapticManager.shared.fire(.tabSelection, isEnabled: preferences.hapticTabSelectionEnabled)
}Design choices to keep
- Haptics should be subtle and not fire on every tiny interaction.
- Respect user preferences (toggle to disable).
- Keep haptic triggers close to the user action, not deep in data layers.
Pitfalls
- Avoid firing multiple haptics in quick succession.
- Do not assume haptics are available; check support.
SwiftUI Image Optimization Reference
Table of Contents
- AsyncImage Best Practices
- Image Decoding and Downsampling (Optional Optimization)
- UIImage Loading and Memory
- SF Symbols
- Summary Checklist
AsyncImage Best Practices
Basic AsyncImage with Phase Handling
// Good - handles loading and error states
AsyncImage(url: imageURL) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
case .failure:
Image(systemName: "photo")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
.frame(width: 200, height: 200)For custom placeholders, replace ProgressView() in the .empty case with your placeholder view. Add .transition(.opacity) to the success case and .animation(.easeInOut, value: imageURL) to the container for fade-in transitions.
Image Decoding and Downsampling (Optional Optimization)
When you encounter `UIImage(data:)` usage, consider suggesting image downsampling as a potential performance improvement, especially for large images in lists or grids.
Current Pattern That Could Be Optimized
// Current pattern - decodes full image on main thread
// Unsafe - force unwrap can crash if imageData is invalid
Image(uiImage: UIImage(data: imageData)!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)Suggested Optimization Pattern
// Suggested optimization - decode and downsample off main thread
struct OptimizedImageView: View {
let imageData: Data
let targetSize: CGSize
@State private var processedImage: UIImage?
var body: some View {
Group {
if let processedImage {
Image(uiImage: processedImage)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
ProgressView()
}
}
.task {
processedImage = await decodeAndDownsample(imageData, targetSize: targetSize)
}
}
private func decodeAndDownsample(_ data: Data, targetSize: CGSize) async -> UIImage? {
await Task.detached {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
return nil
}
let options: [CFString: Any] = [
kCGImageSourceThumbnailMaxPixelSize: max(targetSize.width, targetSize.height),
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
return nil
}
return UIImage(cgImage: cgImage)
}.value
}
}
// Usage
OptimizedImageView(
imageData: imageData,
targetSize: CGSize(width: 200, height: 200)
)Reusable Downsampling Actor
For production use, wrap the logic in an actor with scale-aware sizing and cache-disabled source options:
actor ImageProcessor {
func downsample(data: Data, targetSize: CGSize) -> UIImage? {
let scale = await UIScreen.main.scale
let maxPixel = max(targetSize.width, targetSize.height) * scale
let sourceOptions: [CFString: Any] = [kCGImageSourceShouldCache: false]
guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions as CFDictionary) else { return nil }
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixel,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true
]
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary) else { return nil }
return UIImage(cgImage: cgImage)
}
}Key details: kCGImageSourceShouldCache: false on the source prevents the full-resolution image from being cached in memory. Multiplying targetSize by UIScreen.main.scale ensures the thumbnail is sharp on Retina displays. kCGImageSourceShouldCacheImmediately: true on the thumbnail forces decoding at creation time rather than at first render.
When to Suggest This Optimization
Mention this optimization when you see UIImage(data:) usage, particularly in:
- Scrollable content (List, ScrollView with LazyVStack/LazyHStack)
- Grid layouts with many images
- Image galleries or carousels
- Any scenario where large images are displayed at smaller sizes
Don't automatically apply it—present it as an optional improvement for performance-sensitive scenarios.
UIImage Loading and Memory
UIImage(named:) Caches in System Cache
UIImage(named:) adds images to the system cache, which can cause memory spikes when loading many images (e.g., in a slider or gallery). For single-use or frequently-rotated images, use UIImage(contentsOfFile:) to bypass the cache:
// Caches in system cache -- memory builds up
let image = UIImage(named: "Wallpapers/image_001.jpg")
// No system caching -- memory stays flat
guard let path = Bundle.main.path(forResource: "Wallpapers/image_001.jpg", ofType: nil) else { return nil }
let image = UIImage(contentsOfFile: path)NSCache for Controlled Image Caching
When image processing (resizing, filtering) is needed, use NSCache with a countLimit to bound memory instead of relying on system caching:
struct ImageCache {
private let cache = NSCache<NSString, UIImage>()
init(countLimit: Int = 50) {
cache.countLimit = countLimit
}
subscript(key: String) -> UIImage? {
get { cache.object(forKey: key as NSString) }
nonmutating set {
if let newValue {
cache.setObject(newValue, forKey: key as NSString)
} else {
cache.removeObject(forKey: key as NSString)
}
}
}
}SF Symbols
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
.symbolRenderingMode(.multicolor) // or .hierarchical, .palette, .monochrome
// Animated symbols (iOS 17+)
Image(systemName: "antenna.radiowaves.left.and.right")
.symbolEffect(.variableColor)Variants are available via naming convention: star.circle.fill, star.square.fill, folder.badge.plus.
Summary Checklist
- [ ] Use
AsyncImagewith proper phase handling - [ ] Handle empty, success, and failure states
- [ ] Consider downsampling for
UIImage(data:)in performance-sensitive scenarios - [ ] Decode and downsample images off the main thread
- [ ] Use appropriate target sizes for downsampling
- [ ] Consider image caching for frequently accessed images
- [ ] Use SF Symbols with appropriate rendering modes
Performance Note: Image downsampling is an optional optimization. Only suggest it when you encounter UIImage(data:) usage in performance-sensitive contexts like scrollable lists or grids.
Input toolbar (bottom anchored)
Intent
Use a bottom-anchored input bar for chat, composer, or quick actions without fighting the keyboard.
Core patterns
- Use
.safeAreaInset(edge: .bottom)to anchor the toolbar above the keyboard. - Keep the main content in a
ScrollVieworList. - Drive focus with
@FocusStateand set initial focus when needed. - Avoid embedding the input bar inside the scroll content; keep it separate.
Example: scroll view + bottom input
@MainActor
struct ConversationView: View {
@FocusState private var isInputFocused: Bool
var body: some View {
ScrollViewReader { _ in
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
.padding(.horizontal, .layoutPadding)
}
.safeAreaInset(edge: .bottom) {
InputBar(text: $draft)
.focused($isInputFocused)
}
.scrollDismissesKeyboard(.interactively)
.onAppear { isInputFocused = true }
}
}
}Design choices to keep
- Keep the input bar visually separated from the scrollable content.
- Use
.scrollDismissesKeyboard(.interactively)for chat-like screens. - Ensure send actions are reachable via keyboard return or a clear button.
Pitfalls
- Avoid placing the input view inside the scroll stack; it will jump with content.
- Avoid nested scroll views that fight for drag gestures.
Deprecated API Replacements
Never use these deprecated APIs — all replacements are available on iOS 26+.
Originally from twostraws/SwiftUI-Agent-Skill by Paul Hudson. MIT License.
Compact Replacements
- `navigationTitle(_:)` instead of
navigationBarTitle(_:) - `toolbar { ToolbarItem(...) }` instead of
navigationBarItems(...) - `toolbarVisibility(.hidden, for: .navigationBar)` instead of
navigationBarHidden(_:) - `statusBarHidden(_:)` instead of
statusBar(hidden:) - `ignoresSafeArea(_:edges:)` instead of
edgesIgnoringSafeArea(_:) - `preferredColorScheme(_:)` instead of
colorScheme(_:) - `foregroundStyle(_:)` instead of
foregroundColor(_:) - `clipShape(.rect(cornerRadius:))` instead of
cornerRadius() - `textInputAutocapitalization(_:)` instead of
autocapitalization(_:)(.neverreplaces.none) - `animation(_:value:)` instead of
animation(_:)(always include thevalue:parameter) - `tint(_:)` instead of
accentColor(_:) - `autocorrectionDisabled(_:)` instead of
disableAutocorrection(_:) - `MagnifyGesture` instead of
MagnificationGesture - `RotateGesture` instead of
RotationGesture - `.coordinateSpace(.named("scroll"))` instead of
.coordinateSpace(name: "scroll")
Presentation
Use .confirmationDialog(_:isPresented:actions:message:) instead of actionSheet(...). Use .alert(_:isPresented:actions:message:) instead of alert(isPresented:content:).
.alert("Delete Item?", isPresented: $showAlert) {
Button("Delete", role: .destructive) { deleteItem() }
Button("Cancel", role: .cancel) { }
} message: {
Text("This action cannot be undone.")
}Text Input
Use onSubmit(of:_:) and focused(_:equals:) instead of TextField onEditingChanged/onCommit callbacks.
@FocusState private var isFocused: Bool
TextField("Search", text: $query)
.focused($isFocused)
.onSubmit { performSearch() }Accessibility
Use dedicated modifiers — .accessibilityLabel(), .accessibilityValue(), .accessibilityHint(), .accessibilityAddTraits(), .accessibilityHidden() — instead of the generic .accessibility(...) variants.
Environment Values
Use the @Entry macro instead of manual EnvironmentKey conformance.
extension EnvironmentValues {
@Entry var myCustomValue: String = "Default value"
}Navigation
Use NavigationStack (or NavigationSplitView) instead of NavigationView. Value-based NavigationLink(value:) with .navigationDestination(for:) replaces destination-based links.
NavigationStack {
List(items) { item in
NavigationLink(value: item) { Text(item.name) }
}
.navigationDestination(for: Item.self) { DetailView(item: $0) }
}Clipboard
Prefer PasteButton for user-initiated paste UI — handles permissions automatically.
State Management
Use @Observable instead of ObservableObject. Use @State instead of @StateObject. Use @Bindable instead of @ObservedObject. See state-management.md for full patterns.
Events
Use onChange(of:) { } or onChange(of:) { old, new in } instead of onChange(of:perform:).
- No-parameter:
.onChange(of: value) { doSomething() } - Old and new values:
.onChange(of: value) { old, new in ... } - With initial trigger:
.onChange(of: value, initial: true) { ... }
Sensory Feedback
Use sensoryFeedback(_:trigger:) instead of UIKit feedback generators.
Button("Favorite", systemImage: isFavorite ? "heart.fill" : "heart") {
isFavorite.toggle()
}
.sensoryFeedback(.selection, trigger: isFavorite)Layout
Use containerRelativeFrame() or visualEffect() as alternatives to GeometryReader for sizing and position-based effects. Use onGeometryChange(for:of:action:) to react to geometry changes.
Tabs
Use the Tab API instead of tabItem(_:). When using Tab(role:), all tabs must use Tab syntax.
TabView {
Tab("Home", systemImage: "house") { HomeView() }
Tab("Search", systemImage: "magnifyingglass") { SearchView() }
Tab("Profile", systemImage: "person") { ProfileView() }
}Previews
Use @Previewable for dynamic properties in previews.
#Preview {
@Previewable @State var isOn = false
Toggle("Setting", isOn: $isOn)
}Styling
Use Button instead of onTapGesture() unless you need tap location or count.
iOS 26+ APIs
For Liquid Glass APIs, see the ios-liquid-glass skill.
Scroll Edge Effects
ScrollView { /* content */ }
.scrollEdgeEffectStyle(.soft, for: .top)Background Extension
Image("hero")
.backgroundExtensionEffect()Tab Bar
TabView { /* tabs */ }
.tabBarMinimizeBehavior(.onScrollDown)
.tabViewBottomAccessory { NowPlayingBar() }Use Tab(role: .search) for a dedicated search tab that morphs into a search field.
Toolbars
Use ToolbarSpacer(.fixed) to visually separate toolbar groups. Use sharedBackgroundVisibility(.hidden) to remove glass background from individual items. Use badge(_:) on toolbar item content.
Search
Use searchToolbarBehavior(.minimizable) to opt into a minimized search button.
Animations
Use @Animatable macro instead of manual animatableData. Use @AnimatableIgnored to exclude properties.
Presentations
Use navigationZoomTransition to morph sheets out of their source view with navigationTransitionSource / navigationTransitionDestination.
Controls
controlSize(.extraLarge)for extra-large action buttons.clipShape(.rect(cornerRadius: 12, style: .concentric))for concentric corners- Sliders support tick marks (
SliderTick) and.sliderNeutralValue()
Rich Text
TextEditor accepts AttributedString bindings for rich text editing.
Web Content
WebView displays web content. Use WebPage observable model for richer interaction.
Drag and Drop
Use dragContainer for multi-item drag operations with DragConfiguration.
Quick Lookup Table
| Deprecated | Recommended |
|---|---|
navigationBarTitle(_:) | navigationTitle(_:) |
navigationBarItems(...) | toolbar { ToolbarItem(...) } |
navigationBarHidden(_:) | toolbarVisibility(.hidden, for: .navigationBar) |
edgesIgnoringSafeArea(_:) | ignoresSafeArea(_:edges:) |
foregroundColor(_:) | foregroundStyle(_:) |
cornerRadius(_:) | clipShape(.rect(cornerRadius:)) |
actionSheet(...) | confirmationDialog(...) |
NavigationView | NavigationStack / NavigationSplitView |
accentColor(_:) | tint(_:) |
onChange(of:perform:) | onChange(of:) { old, new in } |
ObservableObject | @Observable |
@StateObject | @State (with @Observable) |
@ObservedObject | @Bindable (with @Observable) |
UIImpactFeedbackGenerator | sensoryFeedback(_:trigger:) |
MagnificationGesture | MagnifyGesture |
RotationGesture | RotateGesture |
tabItem(_:) | Tab API |
Manual animatableData | @Animatable macro |
Manual EnvironmentKey | @Entry macro |
SwiftUI Layout Best Practices Reference
Table of Contents
- Relative Layout Over Constants
- Context-Agnostic Views
- Own Your Container
- Layout Performance
- View Logic and Testability
- Full-Width Views
- Action Handlers
- Summary Checklist
Relative Layout Over Constants
Use dynamic layout calculations instead of hard-coded values.
// Good - relative to actual layout
GeometryReader { geometry in
VStack {
HeaderView()
.frame(height: geometry.size.height * 0.2)
ContentView()
}
}
// Avoid - magic numbers that don't adapt
VStack {
HeaderView()
.frame(height: 150) // Doesn't adapt to different screens
ContentView()
}Why: Hard-coded values don't account for different screen sizes, orientations, or dynamic content (like status bars during phone calls).
Context-Agnostic Views
Views should work in any context. Never assume presentation style or screen size.
// Good - adapts to given space
struct ProfileCard: View {
let user: User
var body: some View {
VStack {
Image(user.avatar)
.resizable()
.aspectRatio(contentMode: .fit)
Text(user.name)
Spacer()
}
.padding()
}
}
// Avoid - assumes full screen
struct ProfileCard: View {
let user: User
var body: some View {
VStack {
Image(user.avatar)
.frame(width: UIScreen.main.bounds.width) // Wrong!
Text(user.name)
}
}
}Why: Views should work as full screens, modals, sheets, popovers, or embedded content.
Own Your Container
Custom views should own static containers but not lazy/repeatable ones.
// Good - owns static container
struct HeaderView: View {
var body: some View {
HStack {
Image(systemName: "star")
Text("Title")
Spacer()
}
}
}
// Avoid - missing container
struct HeaderView: View {
var body: some View {
Image(systemName: "star")
Text("Title")
// Caller must wrap in HStack
}
}
// Good - caller owns lazy container
struct FeedView: View {
let items: [Item]
var body: some View {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
}Layout Performance
Avoid Layout Thrash
Minimize deep view hierarchies and excessive layout dependencies.
// Bad - deep nesting, excessive layout passes
VStack {
HStack {
VStack {
HStack {
VStack {
Text("Deep")
}
}
}
}
}
// Good - flatter hierarchy
VStack {
Text("Shallow")
Text("Structure")
}Avoid excessive `GeometryReader` and preference chains:
// Bad - multiple geometry readers cause layout thrash
GeometryReader { outerGeometry in
VStack {
GeometryReader { innerGeometry in
// Layout recalculates multiple times
}
}
}
// Good - single geometry reader or use alternatives
containerRelativeFrame(.horizontal) { width, _ in
width * 0.8
}Gate frequent geometry updates:
// Bad - updates on every pixel change
.onPreferenceChange(ViewSizeKey.self) { size in
currentSize = size
}
// Good - gate by threshold
.onPreferenceChange(ViewSizeKey.self) { size in
let difference = abs(size.width - currentSize.width)
if difference > 10 { // Only update if significant change
currentSize = size
}
}View Logic and Testability
Keep Business Logic in Services and Models
Business logic belongs in services and models, not in views. Views should stay simple and declarative — orchestrating UI state, not implementing business rules. This makes logic independently testable without requiring view instantiation.
@Observable
@MainActor
final class AuthService {
var email = ""
var password = ""
var isValid: Bool {
!email.isEmpty && password.count >= 8
}
func login() async throws {
// Business logic here — testable without the view
}
}
struct LoginView: View {
@State private var authService = AuthService()
var body: some View {
Form {
TextField("Email", text: $authService.email)
SecureField("Password", text: $authService.password)
Button("Login") {
Task {
try? await authService.login()
}
}
.disabled(!authService.isValid)
}
}
}Avoid embedding business logic directly in view closures (e.g., validation checks inside a Button action). This makes logic untestable without view instantiation.
Note: This is about making business logic testable, not about enforcing a specific architecture. The key is that logic lives outside views where it can be tested independently.
Full-Width Views
When a single view needs to fill the available width, use `.frame(maxWidth: .infinity, alignment:)` instead of wrapping it in a stack with a `Spacer`.
// Good - frame modifier
Text("Hello")
.frame(maxWidth: .infinity, alignment: .leading)
// Avoid - unnecessary stack and spacer
HStack {
Text("Hello")
Spacer()
}Why: .frame(maxWidth:alignment:) is a single modifier that clearly communicates intent. Wrapping in an HStack with a Spacer adds an extra container to the view hierarchy for no benefit.
Action Handlers
Separate layout from logic. View body should reference action methods, not contain inline logic.
// Good - action references method
Button("Publish Project", action: publishService.handlePublish)
// Avoid - multi-line logic in closure
Button("Publish Project") {
isLoading = true
apiService.publish(project) { result in /* ... */ }
}Summary Checklist
- [ ] Use relative layout over hard-coded constants
- [ ] Views work in any context (don't assume screen size)
- [ ] Custom views own static containers
- [ ] Avoid deep view hierarchies (layout thrash)
- [ ] Gate frequent geometry updates by thresholds
- [ ] Business logic kept in services and models (not in views)
- [ ] Action handlers reference methods, not inline logic
- [ ] Use
.frame(maxWidth: .infinity, alignment:)for full-width views (notHStack+Spacer) - [ ] Avoid excessive
GeometryReaderusage - [ ] Use
containerRelativeFrame()when appropriate
Lightweight Clients (Closure-Based)
Use this pattern to keep networking or service dependencies simple and testable without introducing a full view model or heavy DI framework. It works well for SwiftUI apps where you want a small, composable API surface that can be swapped in previews/tests.
Intent
- Provide a tiny "client" type made of async closures.
- Keep business logic in a store or feature layer, not the view.
- Enable easy stubbing in previews/tests.
Minimal shape
struct SomeClient {
var fetchItems: (_ limit: Int) async throws -> [Item]
var search: (_ query: String, _ limit: Int) async throws -> [Item]
}
extension SomeClient {
static func live(baseURL: URL = URL(string: "https://example.com")!) -> SomeClient {
let session = URLSession.shared
return SomeClient(
fetchItems: { limit in
// build URL, call session, decode
},
search: { query, limit in
// build URL, call session, decode
}
)
}
}Usage pattern
@MainActor
@Observable final class ItemsStore {
enum LoadState { case idle, loading, loaded, failed(String) }
var items: [Item] = []
var state: LoadState = .idle
private let client: SomeClient
init(client: SomeClient) {
self.client = client
}
func load(limit: Int = 20) async {
state = .loading
do {
items = try await client.fetchItems(limit)
state = .loaded
} catch {
state = .failed(error.localizedDescription)
}
}
}struct ContentView: View {
@Environment(ItemsStore.self) private var store
var body: some View {
List(store.items) { item in
Text(item.title)
}
.task { await store.load() }
}
}@main
struct MyApp: App {
@State private var store = ItemsStore(client: .live())
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}Guidance
- Keep decoding and URL-building in the client; keep state changes in the store.
- Make the store accept the client in
initand keep it private. - Avoid global singletons; use
.environmentfor store injection. - If you need multiple variants (mock/stub), add
static func mock(...).
Pitfalls
- Don’t put UI state in the client; keep state in the store.
- Don’t capture
selfor view state in the client closures.
List and Section
Intent
Use List for feed-style content and settings-style rows where built-in row reuse, selection, and accessibility matter.
Core patterns
- Prefer
Listfor long, vertically scrolling content with repeated rows. - Use
Sectionheaders to group related rows. - Pair with
ScrollViewReaderwhen you need scroll-to-top or jump-to-id. - Use
.listStyle(.plain)for modern feed layouts. - Use
.listStyle(.grouped)for multi-section discovery/search pages where section grouping helps. - Apply
.scrollContentBackground(.hidden)+ a custom background when you need a themed surface. - Use
.listRowInsets(...)and.listRowSeparator(.hidden)to tune row spacing and separators. - Use
.environment(\\.defaultMinListRowHeight, ...)to control dense list layouts.
Example: feed list with scroll-to-top
@MainActor
struct TimelineListView: View {
@Environment(\.selectedTabScrollToTop) private var selectedTabScrollToTop
@State private var scrollToId: String?
var body: some View {
ScrollViewReader { proxy in
List {
ForEach(items) { item in
TimelineRow(item: item)
.id(item.id)
.listRowInsets(.init(top: 12, leading: 16, bottom: 6, trailing: 16))
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.environment(\\.defaultMinListRowHeight, 1)
.onChange(of: scrollToId) { _, newValue in
if let newValue {
proxy.scrollTo(newValue, anchor: .top)
scrollToId = nil
}
}
.onChange(of: selectedTabScrollToTop) { _, newValue in
if newValue == 0 {
withAnimation {
proxy.scrollTo(ScrollToView.Constants.scrollToTop, anchor: .top)
}
}
}
}
}
}Example: settings-style list
@MainActor
struct SettingsView: View {
var body: some View {
List {
Section("General") {
NavigationLink("Display") { DisplaySettingsView() }
NavigationLink("Haptics") { HapticsSettingsView() }
}
Section("Account") {
Button("Sign Out", role: .destructive) {}
}
}
.listStyle(.insetGrouped)
}
}Design choices to keep
- Use
Listfor dynamic feeds, settings, and any UI where row semantics help. - Use stable IDs for rows to keep animations and scroll positioning reliable.
- Prefer
.contentShape(Rectangle())on rows that should be tappable end-to-end. - Use
.refreshablefor pull-to-refresh feeds when the data source supports it.
Empty States with ContentUnavailableView
Use ContentUnavailableView for empty list and search states. The built-in .search variant is auto-localized:
List {
ForEach(searchResults) { item in
ItemRow(item: item)
}
}
.overlay {
if searchResults.isEmpty, !searchText.isEmpty {
ContentUnavailableView.search(text: searchText)
}
}For non-search empty states, use a custom instance:
ContentUnavailableView(
"No Articles",
systemImage: "doc.richtext.fill",
description: Text("Articles you save will appear here.")
)Table
A multi-column data container that presents rows of Identifiable data with sortable, selectable columns. On compact size classes (iPhone, iPad Slide Over), columns after the first are automatically hidden.
Basic Table
struct Person: Identifiable {
let givenName: String
let familyName: String
let emailAddress: String
let id = UUID()
var fullName: String { givenName + " " + familyName }
}
struct PeopleTable: View {
@State private var people: [Person] = [ /* ... */ ]
var body: some View {
Table(people) {
TableColumn("Given Name", value: \.givenName)
TableColumn("Family Name", value: \.familyName)
TableColumn("E-Mail Address", value: \.emailAddress)
}
}
}Table with Selection
Bind to a single ID for single-selection, or a Set<ID> for multi-selection:
struct SelectableTable: View {
@State private var people: [Person] = [ /* ... */ ]
@State private var selectedPeople = Set<Person.ID>()
var body: some View {
Table(people, selection: $selectedPeople) {
TableColumn("Given Name", value: \.givenName)
TableColumn("Family Name", value: \.familyName)
TableColumn("E-Mail Address", value: \.emailAddress)
}
Text("\(selectedPeople.count) people selected")
}
}Sortable Table
Provide a binding to [KeyPathComparator] and re-sort the data in .onChange(of:):
struct SortableTable: View {
@State private var people: [Person] = [ /* ... */ ]
@State private var sortOrder = [KeyPathComparator(\Person.givenName)]
var body: some View {
Table(people, sortOrder: $sortOrder) {
TableColumn("Given Name", value: \.givenName)
TableColumn("Family Name", value: \.familyName)
TableColumn("E-Mail Address", value: \.emailAddress)
}
.onChange(of: sortOrder) { _, newOrder in
people.sort(using: newOrder)
}
}
}Important: The table does not sort data itself — you must re-sort the collection when sortOrder changes.
Adaptive Table for Compact Size Classes
On iPhone or iPad in Slide Over, only the first column is shown. Customize it to display combined information:
struct AdaptiveTable: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompact: Bool { horizontalSizeClass == .compact }
@State private var people: [Person] = [ /* ... */ ]
@State private var sortOrder = [KeyPathComparator(\Person.givenName)]
var body: some View {
Table(people, sortOrder: $sortOrder) {
TableColumn("Given Name", value: \.givenName) { person in
VStack(alignment: .leading) {
Text(isCompact ? person.fullName : person.givenName)
if isCompact {
Text(person.emailAddress)
.foregroundStyle(.secondary)
}
}
}
TableColumn("Family Name", value: \.familyName)
TableColumn("E-Mail Address", value: \.emailAddress)
}
.onChange(of: sortOrder) { _, newOrder in
people.sort(using: newOrder)
}
}
}Table with Dynamic Columns
Use TableColumnForEach when the number of columns is not known at compile time:
@MainActor
@Observable
class AudioSampleTrack {
let channels: [AudioChannel]
var samples: [AudioSample]
}
struct ContentView: View {
var track: AudioSampleTrack
var body: some View {
Table(track.samples) {
TableColumn("Timestamp (ms)") { sample in
Text(sample.timestamp, format: .number.scale(1000))
.monospacedDigit()
}
TableColumnForEach(track.channels) { channel in
TableColumn(channel.name) { sample in
Text(sample.level(channel: channel.id),
format: .number.precision(.fractionLength(2))
)
.monospacedDigit()
}
.width(ideal: 70)
.alignment(.numeric)
}
}
}
}Table Styles
// Inset (no borders)
Table(people) { /* columns */ }
.tableStyle(.inset)
// Hide column headers
Table(people) { /* columns */ }
.tableColumnHeaders(.hidden)Platform Behavior
| Platform | Behavior |
|---|---|
| iPadOS (regular) | Full multi-column layout; headers and all columns visible |
| iPadOS (compact) | Only the first column shown; headers hidden |
| iPhone (all sizes) | Only the first column shown; headers hidden; list-like appearance |
Prefer handling the compact size class by showing combined info in the first column. This provides a seamless transition when the size class changes.
Pitfalls
- Avoid heavy custom layouts inside a
Listrow; useScrollView+LazyVStackinstead. - Be careful mixing
Listand nestedScrollView; it can cause gesture conflicts. - The table does not sort data itself — re-sort in
.onChange(of: sortOrder).
Loading & Placeholders
Use this when a view needs a consistent loading state (skeletons, redaction, empty state) without blocking interaction.
Patterns to prefer
- Redacted placeholders for list/detail content to preserve layout while loading.
- ContentUnavailableView for empty or error states after loading completes.
- ProgressView only for short, global operations (use sparingly in content-heavy screens).
Recommended approach
1. Keep the real layout, render placeholder data, then apply .redacted(reason: .placeholder). 2. For lists, show a fixed number of placeholder rows (avoid infinite spinners). 3. Switch to ContentUnavailableView when load finishes but data is empty.
Pitfalls
- Don’t animate layout shifts during redaction; keep frames stable.
- Avoid nesting multiple spinners; use one loading indicator per section.
- Keep placeholder count small (3–6) to reduce jank on low-end devices.
Minimal usage
VStack {
if isLoading {
ForEach(0..<3, id: \.self) { _ in
RowView(model: .placeholder())
}
.redacted(reason: .placeholder)
} else if items.isEmpty {
ContentUnavailableView("No items", systemImage: "tray")
} else {
ForEach(items) { item in RowView(model: item) }
}
}macOS Settings
Intent
Use this when building a macOS Settings window backed by SwiftUI's Settings scene.
Core patterns
- Declare the Settings scene in the
Appand compile it only for macOS. - Keep settings content in a dedicated root view (
SettingsView) and drive values with@AppStorage. - Use
TabViewto group settings sections when you have more than one category. - Use
Forminside each tab to keep controls aligned and accessible. - Use
OpenSettingsActionorSettingsLinkfor in-app entry points to the Settings window.
Example: settings scene
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
#if os(macOS)
Settings {
SettingsView()
}
#endif
}
}Example: tabbed settings view
@MainActor
struct SettingsView: View {
@AppStorage("showPreviews") private var showPreviews = true
@AppStorage("fontSize") private var fontSize = 12.0
var body: some View {
TabView {
Form {
Toggle("Show Previews", isOn: $showPreviews)
Slider(value: $fontSize, in: 9...96) {
Text("Font Size (\(fontSize, specifier: "%.0f") pts)")
}
}
.tabItem { Label("General", systemImage: "gear") }
Form {
Toggle("Enable Advanced Mode", isOn: .constant(false))
}
.tabItem { Label("Advanced", systemImage: "star") }
}
.scenePadding()
.frame(maxWidth: 420, minHeight: 240)
}
}Skip navigation
- Avoid wrapping
SettingsViewin aNavigationStackunless you truly need deep push navigation. - Prefer tabs or sections; Settings is already presented as a separate window and should feel flat.
- If you must show hierarchical settings, use a single
NavigationSplitViewwith a sidebar list of categories.
Pitfalls
- Don’t reuse iOS-only settings layouts (full-screen stacks, toolbar-heavy flows).
- Avoid large custom view hierarchies inside
Form; keep rows focused and accessible.
Matched transitions
Intent
Use matched transitions to create smooth continuity between a source view (thumbnail, avatar) and a destination view (sheet, detail, viewer).
Core patterns
- Use a shared
Namespaceand a stable ID for the source. - Use
matchedTransitionSource+navigationTransition(.zoom(...))on iOS 26+. - Use
matchedGeometryEffectfor in-place transitions within a view hierarchy. - Keep IDs stable across view updates (avoid random UUIDs).
Example: media preview to full-screen viewer (iOS 26+)
struct MediaPreview: View {
@Namespace private var namespace
@State private var selected: MediaAttachment?
var body: some View {
ThumbnailView()
.matchedTransitionSource(id: selected?.id ?? "", in: namespace)
.sheet(item: $selected) { item in
MediaViewer(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
}
}
}Example: matched geometry within a view
struct ToggleBadge: View {
@Namespace private var space
@State private var isOn = false
var body: some View {
Button {
withAnimation(.spring) { isOn.toggle() }
} label: {
Image(systemName: isOn ? "eye" : "eye.slash")
.matchedGeometryEffect(id: "icon", in: space)
}
}
}Design choices to keep
- Prefer
matchedTransitionSourcefor cross-screen transitions. - Keep source and destination sizes reasonable to avoid jarring scale changes.
- Use
withAnimationfor state-driven transitions.
Pitfalls
- Don’t use unstable IDs; it breaks the transition.
- Avoid mismatched shapes (e.g., square to circle) unless the design expects it.
Media (images, video, viewer)
Intent
Use consistent patterns for loading images, previewing media, and presenting a full-screen viewer.
Core patterns
- Use
LazyImage(orAsyncImage) for remote images with loading states. - Prefer a lightweight preview component for inline media.
- Use a shared viewer state (e.g.,
QuickLook) to present a full-screen media viewer. - Use
openWindowfor desktop/visionOS and a sheet for iOS.
Example: inline media preview
struct MediaPreviewRow: View {
@Environment(QuickLook.self) private var quickLook
let attachments: [MediaAttachment]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(attachments) { attachment in
LazyImage(url: attachment.previewURL) { state in
if let image = state.image {
image.resizable().aspectRatio(contentMode: .fill)
} else {
ProgressView()
}
}
.frame(width: 120, height: 120)
.clipped()
.onTapGesture {
quickLook.prepareFor(
selectedMediaAttachment: attachment,
mediaAttachments: attachments
)
}
}
}
}
}
}Example: global media viewer sheet
struct AppRoot: View {
@State private var quickLook = QuickLook.shared
var body: some View {
content
.environment(quickLook)
.sheet(item: $quickLook.selectedMediaAttachment) { selected in
MediaUIView(selectedAttachment: selected, attachments: quickLook.mediaAttachments)
}
}
}Design choices to keep
- Keep previews lightweight; load full media in the viewer.
- Use shared viewer state so any view can open media without prop-drilling.
- Use a single entry point for the viewer (sheet/window) to avoid duplicates.
Pitfalls
- Avoid loading full-size images in list rows; use resized previews.
- Don’t present multiple viewer sheets at once; keep a single source of truth.
Menu Bar
Intent
Use this when adding or customizing the macOS/iPadOS menu bar with SwiftUI commands.
Core patterns
- Add commands at the
Scenelevel with.commands { ... }. - Use
SidebarCommands()when your UI includes a navigation sidebar. - Use
CommandMenufor app-specific menus and group related actions. - Use
CommandGroupto insert items before/after system groups or replace them. - Use
FocusedValuefor context-sensitive menu items that depend on the active scene.
Example: basic command menu
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandMenu("Actions") {
Button("Run", action: run)
.keyboardShortcut("R")
Button("Stop", action: stop)
.keyboardShortcut(".")
}
}
}
private func run() {}
private func stop() {}
}Example: insert and replace groups
WindowGroup {
ContentView()
}
.commands {
CommandGroup(before: .systemServices) {
Button("Check for Updates") { /* open updater */ }
}
CommandGroup(after: .newItem) {
Button("New from Clipboard") { /* create item */ }
}
CommandGroup(replacing: .help) {
Button("User Manual") { /* open docs */ }
}
}Example: focused menu state
@Observable
final class DataModel {
var items: [String] = []
}
struct ContentView: View {
@State private var model = DataModel()
var body: some View {
List(model.items, id: \.self) { item in
Text(item)
}
.focusedSceneValue(model)
}
}
struct ItemCommands: Commands {
@FocusedValue(DataModel.self) private var model: DataModel?
var body: some Commands {
CommandGroup(after: .newItem) {
Button("New Item") {
model?.items.append("Untitled")
}
.disabled(model == nil)
}
}
}Menu bar and Settings
- Defining a
Settingsscene adds the Settings menu item on macOS automatically. - If you need a custom entry point inside the app, use
OpenSettingsActionorSettingsLink.
Pitfalls
- Avoid registering the same keyboard shortcut in multiple command groups.
- Don’t use menu items as the only discoverable entry point for critical features.
NavigationStack
Intent
Use this pattern for programmatic navigation and deep links, especially when each tab needs an independent navigation history. The key idea is one NavigationStack per tab, each with its own path binding and router object.
Core architecture
- Define a route enum that is
Hashableand represents all destinations. - Create a lightweight router (or use a library such as
https://github.com/Dimillian/AppRouter) that owns thepathand any sheet state. - Each tab owns its own router instance and binds
NavigationStack(path:)to it. - Inject the router into the environment so child views can navigate programmatically.
- Centralize destination mapping with a single
navigationDestination(for:)block (or awithAppRouter()modifier).
Example: custom router with per-tab stack
@MainActor
@Observable
final class RouterPath {
var path: [Route] = []
var presentedSheet: SheetDestination?
func navigate(to route: Route) {
path.append(route)
}
func reset() {
path = []
}
}
enum Route: Hashable {
case account(id: String)
case status(id: String)
}
@MainActor
struct TimelineTab: View {
@State private var routerPath = RouterPath()
var body: some View {
NavigationStack(path: $routerPath.path) {
TimelineView()
.navigationDestination(for: Route.self) { route in
switch route {
case .account(let id): AccountView(id: id)
case .status(let id): StatusView(id: id)
}
}
}
.environment(routerPath)
}
}Example: centralized destination mapping
Use a shared view modifier to avoid duplicating route switches across screens.
extension View {
func withAppRouter() -> some View {
navigationDestination(for: Route.self) { route in
switch route {
case .account(let id):
AccountView(id: id)
case .status(let id):
StatusView(id: id)
}
}
}
}Then apply it once per stack:
NavigationStack(path: $routerPath.path) {
TimelineView()
.withAppRouter()
}Example: binding per tab (tabs with independent history)
@MainActor
struct TabsView: View {
@State private var timelineRouter = RouterPath()
@State private var notificationsRouter = RouterPath()
var body: some View {
TabView {
TimelineTab(router: timelineRouter)
NotificationsTab(router: notificationsRouter)
}
}
}Example: generic tabs with per-tab NavigationStack
Use this when tabs are built from data and each needs its own path without hard-coded names.
@MainActor
struct TabsView: View {
@State private var selectedTab: AppTab = .timeline
@State private var tabRouter = TabRouter()
var body: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
NavigationStack(path: tabRouter.binding(for: tab)) {
tab.makeContentView()
}
.environment(tabRouter.router(for: tab))
.tabItem { tab.label }
.tag(tab)
}
}
}
}@MainActor @Observable final class TabRouter { private var routers: [AppTab: RouterPath] = [:]
func router(for tab: AppTab) -> RouterPath { if let router = routers[tab] { return router } let router = RouterPath() routers[tab] = router return router }
func binding(for tab: AppTab) -> Binding<[Route]> { let router = router(for: tab) return Binding(get: { router.path }, set: { router.path = $0 }) } }
Design choices to keep
- One
NavigationStackper tab to preserve independent history. - A single source of truth for navigation state (
RouterPathor library router). - Use
navigationDestination(for:)to map routes to views. - Reset the path when app context changes (account switch, logout, etc.).
- Inject the router into the environment so child views can navigate and present sheets without prop-drilling.
- Keep sheet presentation state on the router if you want a single place to manage modals.
Pitfalls
- Do not share one path across all tabs unless you want global history.
- Ensure route identifiers are stable and
Hashable. - Avoid storing view instances in the path; store lightweight route data instead.
- If using a router object, keep it outside other
@Observableobjects to avoid nested observation.
Overlay and toasts
Intent
Use overlays for transient UI (toasts, banners, loaders) without affecting layout.
Core patterns
- Use
.overlay(alignment:)to place global UI without changing the underlying layout. - Keep overlays lightweight and dismissible.
- Use a dedicated
ToastCenter(or similar) for global state if multiple features trigger toasts.
Example: toast overlay
struct AppRootView: View {
@State private var toast: Toast?
var body: some View {
content
.overlay(alignment: .top) {
if let toast {
ToastView(toast: toast)
.transition(.move(edge: .top).combined(with: .opacity))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { self.toast = nil }
}
}
}
}
}
}Design choices to keep
- Prefer overlays for transient UI rather than embedding in layout stacks.
- Use transitions and short auto-dismiss timers.
- Keep the overlay aligned to a clear edge (
.topor.bottom).
Pitfalls
- Avoid overlays that block all interaction unless explicitly needed.
- Don’t stack many overlays; use a queue or replace the current toast.
Performance guardrails
Intent
Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated, or at risk of unnecessary recomputation.
Core rules
- Give
ForEachand list content stable identity. Do not use unstable indices as identity when the collection can reorder or mutate. - Keep expensive filtering, sorting, and formatting out of
body; precompute or move it into a model/helper when it is not trivial. - Narrow observation scope so only the views that read changing state need to update.
- Prefer lazy containers for larger scrolling content and extract subviews when only part of a screen changes frequently.
- Avoid swapping entire top-level view trees for small state changes; keep a stable root view and vary localized sections or modifiers.
Example: stable identity
ForEach(items) { item in
Row(item: item)
}Prefer that over index-based identity when the collection can change order:
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
Row(item: item)
}Example: move expensive work out of body
struct FeedView: View {
let items: [FeedItem]
private var sortedItems: [FeedItem] {
items.sorted(using: KeyPathComparator(\.createdAt, order: .reverse))
}
var body: some View {
List(sortedItems) { item in
FeedRow(item: item)
}
}
}If the work is more expensive than a small derived property, move it into a model, store, or helper that updates less often.
When to investigate further
- Janky scrolling in long feeds or grids
- Typing lag from search or form validation
- Overly broad view updates when one small piece of state changes
- Large screens with many conditionals or repeated formatting work
Pitfalls
- Recomputing heavy transforms every render
- Observing a large object from many descendants when only one field matters
- Building custom scroll containers when
List,LazyVStack, orLazyHGridwould already solve the problem
Previews
Intent
Use previews to validate layout, state wiring, and injected dependencies without relying on a running app or live services.
Core rules
- Add
#Previewcoverage for the primary state plus important secondary states such as loading, empty, and error. - Use deterministic fixtures, mocks, and sample data. Do not make previews depend on live network calls, real databases, or global singletons.
- Install required environment dependencies directly in the preview so the view can render in isolation.
- Keep preview setup close to the view until it becomes noisy; then extract lightweight preview helpers or fixtures.
- If a preview crashes, fix the state initialization or dependency wiring before expanding the feature further.
Example: simple preview states
#Preview("Loaded") {
ProfileView(profile: .fixture)
}
#Preview("Empty") {
ProfileView(profile: nil)
}Example: preview with injected dependencies
#Preview("Search results") {
SearchView()
.environment(SearchClient.preview(results: [.fixture, .fixture2]))
.environment(Theme.preview)
}Preview checklist
- Does the preview install every required environment dependency?
- Does it cover at least one success path and one non-happy path?
- Are fixtures stable and small enough to be read quickly?
- Can the preview render without network, auth, or app-global initialization?
Pitfalls
- Do not hide preview crashes by making dependencies optional if the production view requires them.
- Avoid huge inline fixtures when a named sample is easier to read.
- Do not couple previews to global shared singletons unless the project has no alternative.
SwiftUI ScrollView Patterns
Originally from AvdLee/SwiftUI-Agent-Skill by Antoine van der Lee and Omar Elsayed. MIT License.
ScrollViewReader for Programmatic Scrolling
Use ScrollViewReader for scroll-to-top, scroll-to-bottom, and anchor-based jumps.
struct ChatView: View {
@State private var messages: [Message] = []
private let bottomID = "bottom"
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
.id(message.id)
}
Color.clear
.frame(height: 1)
.id(bottomID)
}
}
.onChange(of: messages.count) { _, _ in
withAnimation {
proxy.scrollTo(bottomID, anchor: .bottom)
}
}
}
}
}Scroll-to-Top Pattern
struct FeedView: View {
@State private var items: [Item] = []
@State private var scrollToTop = false
private let topID = "top"
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
Color.clear
.frame(height: 1)
.id(topID)
ForEach(items) { item in
ItemRow(item: item)
}
}
}
.onChange(of: scrollToTop) { _, shouldScroll in
if shouldScroll {
withAnimation {
proxy.scrollTo(topID, anchor: .top)
}
scrollToTop = false
}
}
}
}
}Scroll Position Tracking
Storing scroll position directly triggers view updates on every scroll frame. Gate updates by threshold instead.
// GOOD — only updates state when crossing threshold
struct ContentView: View {
@State private var startAnimation: Bool = false
var body: some View {
ScrollView {
content
.background(
GeometryReader { geometry in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named("scroll")).minY
)
}
)
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
if value < -100 {
startAnimation = true
} else {
startAnimation = false
}
}
}
}
struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}Scroll-Based Header Visibility
struct ContentView: View {
@State private var showHeader = true
var body: some View {
VStack(spacing: 0) {
if showHeader {
HeaderView()
.transition(.move(edge: .top))
}
ScrollView {
content
.background(
GeometryReader { geometry in
Color.clear
.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named("scroll")).minY
)
}
)
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
if offset < -50 {
withAnimation { showHeader = false }
} else if offset > 50 {
withAnimation { showHeader = true }
}
}
}
}
}Scroll Transitions and Effects
Scroll-Based Opacity
ScrollView {
LazyVStack(spacing: 20) {
ForEach(items) { item in
ItemCard(item: item)
.visualEffect { content, geometry in
let frame = geometry.frame(in: .scrollView)
let distance = min(0, frame.minY)
return content
.opacity(1 + distance / 200)
}
}
}
}Parallax Effect
ScrollView {
VStack(spacing: 0) {
Image("hero")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 300)
.visualEffect { content, geometry in
let offset = geometry.frame(in: .scrollView).minY
return content
.offset(y: offset > 0 ? -offset * 0.5 : 0)
}
.clipped()
ContentView()
}
}Scroll Target Behavior
Paging ScrollView
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(pages) { page in
PageView(page: page)
.containerRelativeFrame(.horizontal)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)Snap to Items
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(items) { item in
ItemCard(item: item)
.frame(width: 280)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.contentMargins(.horizontal, 20)Summary Checklist
- [ ] Use
ScrollViewReaderwith stable IDs for programmatic scrolling - [ ] Always use explicit animations with
scrollTo() - [ ] Use
.visualEffectfor scroll-based visual changes - [ ] Use
.scrollTargetBehavior(.paging)for paging behavior - [ ] Use
.scrollTargetBehavior(.viewAligned)for snap-to-item behavior - [ ] Gate frequent scroll position updates by thresholds
Scroll-reveal detail surfaces
Intent
Use this pattern when a detail screen has a primary surface first and secondary content behind it, and you want the user to reveal that secondary layer by scrolling or swiping instead of tapping a separate button.
Typical fits:
- media detail screens that reveal actions or metadata
- maps, cards, or canvases that transition into structured detail
- full-screen viewers with a second "actions" or "insights" page
Core pattern
Build the interaction as a paged vertical ScrollView with two sections:
1. a primary section sized to the viewport 2. a secondary section below it
Derive a normalized progress value from the vertical content offset and drive all visual changes from that one value.
Avoid treating the reveal as a separate gesture system unless scroll alone cannot express it.
Minimal structure
private enum DetailSection: Hashable {
case primary
case secondary
}
struct DetailSurface: View {
@State private var revealProgress: CGFloat = 0
@State private var secondaryHeight: CGFloat = 1
var body: some View {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
PrimaryContent(progress: revealProgress)
.frame(height: geometry.size.height)
.id(DetailSection.primary)
SecondaryContent(progress: revealProgress)
.id(DetailSection.secondary)
.onGeometryChange(for: CGFloat.self) { geo in
geo.size.height
} action: { newHeight in
secondaryHeight = max(newHeight, 1)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
.onScrollGeometryChange(for: CGFloat.self, of: { scroll in
scroll.contentOffset.y + scroll.contentInsets.top
}) { _, offset in
revealProgress = (offset / secondaryHeight).clamped(to: 0...1)
}
.safeAreaInset(edge: .bottom) {
ChevronAffordance(progress: revealProgress) {
withAnimation(.smooth) {
let target: DetailSection = revealProgress < 0.5 ? .secondary : .primary
proxy.scrollTo(target, anchor: .top)
}
}
}
}
}
}
}Design choices to keep
- Make the primary section exactly viewport-sized when the interaction should feel like paging between states.
- Compute
progressfrom real scroll offset, not from duplicated booleans likeisExpanded,isShowingSecondary, andisSnapped. - Use
progressto driveoffset,opacity,blur,scaleEffect, and toolbar state so the whole surface stays synchronized. - Use
ScrollViewReaderfor programmatic snapping from taps on the primary content or chevron affordances. - Use
onScrollTargetVisibilityChangewhen you need a settled section state for haptics, tooltip dismissal, analytics, or accessibility announcements.
Morphing a shared control
If a control appears to move from the primary surface into the secondary content, do not render two fully visible copies.
Instead:
- expose a source anchor in the primary area
- expose a destination anchor in the secondary area
- render one overlay that interpolates position and size using
progress
Color.clear
.anchorPreference(key: ControlAnchorKey.self, value: .bounds) { anchor in
["source": anchor]
}
Color.clear
.anchorPreference(key: ControlAnchorKey.self, value: .bounds) { anchor in
["destination": anchor]
}
.overlayPreferenceValue(ControlAnchorKey.self) { anchors in
MorphingControlOverlay(anchors: anchors, progress: revealProgress)
}This keeps the motion coherent and avoids duplicate-hit-target bugs.
Haptics and affordances
- Use light threshold haptics when the reveal begins and stronger haptics near the committed state.
- Keep a visible affordance like a chevron or pill while
progressis near zero. - Flip, fade, or blur the affordance as the secondary section becomes active.
Interaction guards
- Disable vertical scrolling when a conflicting mode is active, such as pinch-to-zoom, crop, or full-screen media manipulation.
- Disable hit testing on overlays that should disappear once the secondary content is revealed.
- Avoid same-axis nested scroll views unless the inner view is effectively static or disabled during the reveal.
Pitfalls
- Do not hard-code the progress divisor. Measure the secondary section height or another real reveal distance.
- Do not mix multiple animation sources for the same property. If
progressdrives it, keep other animations off that property. - Do not store derived state like
isSecondaryVisibleunless another API requires it. Prefer deriving it fromprogressor visible scroll targets. - Beware of layout feedback loops when measuring heights. Clamp zero values and update only when the measured height actually changes.
Concrete example
The scroll-reveal pattern suits a tile detail screen where the primary tile fills the viewport and secondary content (intents, actions, metadata) slides in as the user scrolls down.
ScrollView and Lazy stacks
Intent
Use ScrollView with LazyVStack, LazyHStack, or LazyVGrid when you need custom layout, mixed content, or horizontal/ grid-based scrolling.
Core patterns
- Prefer
ScrollView+LazyVStackfor chat-like or custom feed layouts. - Use
ScrollView(.horizontal)+LazyHStackfor chips, tags, avatars, and media strips. - Use
LazyVGridfor icon/media grids; prefer adaptive columns when possible. - Use
ScrollViewReaderfor scroll-to-top/bottom and anchor-based jumps. - Use
safeAreaInset(edge:)for input bars that should stick above the keyboard.
Example: vertical custom feed
@MainActor
struct ConversationView: View {
private enum Constants { static let bottomAnchor = "bottom" }
@State private var scrollProxy: ScrollViewProxy?
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
.id(message.id)
}
Color.clear.frame(height: 1).id(Constants.bottomAnchor)
}
.padding(.horizontal, .layoutPadding)
}
.safeAreaInset(edge: .bottom) {
MessageInputBar()
}
.onAppear {
scrollProxy = proxy
withAnimation {
proxy.scrollTo(Constants.bottomAnchor, anchor: .bottom)
}
}
}
}
}Example: horizontal chips
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(chips) { chip in
ChipView(chip: chip)
}
}
}Example: adaptive grid
let columns = [GridItem(.adaptive(minimum: 120))]
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(items) { item in
GridItemView(item: item)
}
}
.padding(8)
}Design choices to keep
- Use
Lazy*stacks when item counts are large or unknown. - Use non-lazy stacks for small, fixed-size content to avoid lazy overhead.
- Keep IDs stable when using
ScrollViewReader. - Prefer explicit animations (
withAnimation) when scrolling to an ID.
Pitfalls
- Avoid nesting scroll views of the same axis; it causes gesture conflicts.
- Don’t combine
ListandScrollViewin the same hierarchy without a clear reason. - Overuse of
LazyVStackfor tiny content can add unnecessary complexity.
Searchable
Intent
Use searchable to add native search UI with optional scopes and async results.
Core patterns
- Bind
searchable(text:)to local state. - Use
.searchScopesfor multiple search modes. - Use
.task(id: searchQuery)or debounced tasks to avoid overfetching. - Show placeholders or progress states while results load.
Example: searchable with scopes
@MainActor
struct ExploreView: View {
@State private var searchQuery = ""
@State private var searchScope: SearchScope = .all
@State private var isSearching = false
@State private var results: [SearchResult] = []
var body: some View {
List {
if isSearching {
ProgressView()
} else {
ForEach(results) { result in
SearchRow(result: result)
}
}
}
.searchable(
text: $searchQuery,
placement: .navigationBarDrawer(displayMode: .always),
prompt: Text("Search")
)
.searchScopes($searchScope) {
ForEach(SearchScope.allCases, id: \.self) { scope in
Text(scope.title)
}
}
.task(id: searchQuery) {
await runSearch()
}
}
private func runSearch() async {
guard !searchQuery.isEmpty else {
results = []
return
}
isSearching = true
defer { isSearching = false }
try? await Task.sleep(for: .milliseconds(250))
results = await fetchResults(query: searchQuery, scope: searchScope)
}
}Design choices to keep
- Show a placeholder when search is empty or has no results.
- Debounce input to avoid spamming the network.
- Keep search state local to the view.
Pitfalls
- Avoid running searches for empty strings.
- Don’t block the main thread during fetch.
SwiftUI Sheet, Navigation & Inspector Patterns Reference
Table of Contents
- Sheet Patterns
- Navigation Patterns
- Multi-Column Navigation with NavigationSplitView
- Inspector
- Presentation Modifiers
- Summary Checklist
Sheet Patterns
Item-Driven Sheets (Preferred)
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 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.
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:
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.
Navigation Patterns
Type-Safe Navigation with NavigationStack
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
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+.
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
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)withNavigationSplitViewVisibility(.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
| 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 |
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
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
MyEditorView()
.inspector(isPresented: $showInspector) {
InspectorContent()
.inspectorColumnWidth(min: 200, ideal: 250, max: 400)
}Inspector with Fixed Width
MyEditorView()
.inspector(isPresented: $showInspector) {
InspectorContent()
.inspectorColumnWidth(300)
}Platform Behavior
| 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: UseInspectorCommandsin your app's.commandsto include the default inspector toggle keyboard shortcut.
Presentation Modifiers
Full Screen Cover
struct ContentView: View {
@State private var showFullScreen = false
var body: some View {
Button("Show Full Screen") {
showFullScreen = true
}
.fullScreenCover(isPresented: $showFullScreen) {
FullScreenView()
}
}
}Popover
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
NavigationStackwithnavigationDestination(for:)for type-safe navigation - [ ] Use
NavigationPathfor programmatic navigation - [ ] Use
NavigationSplitViewfor sidebar-driven multi-column layouts - [ ] Use
Inspectorfor trailing-edge supplementary panels - [ ] Set column widths with
navigationSplitViewColumnWidth(min:ideal:max:)orinspectorColumnWidth(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
Identifiabletype with.sheet(item:)when presenting multiple sheets - [ ] Navigation state can be saved/restored when needed
Sheets
Intent
Use a centralized sheet routing pattern so any view can present modals without prop-drilling. This keeps sheet state in one place and scales as the app grows.
Core architecture
- Define a
SheetDestinationenum that describes every modal and isIdentifiable. - Store the current sheet in a router object (
presentedSheet: SheetDestination?). - Create a view modifier like
withSheetDestinations(...)that maps the enum to concrete sheet views. - Inject the router into the environment so child views can set
presentedSheetdirectly.
Example: item-driven local sheet
Use this when sheet state is local to one screen and does not need centralized routing.
@State private var selectedItem: Item?
.sheet(item: $selectedItem) { item in
EditItemSheet(item: item)
}Example: SheetDestination enum
enum SheetDestination: Identifiable, Hashable {
case composer
case editProfile
case settings
case report(itemID: String)
var id: String {
switch self {
case .composer, .editProfile:
// Use the same id to ensure only one editor-like sheet is active at a time.
return "editor"
case .settings:
return "settings"
case .report:
return "report"
}
}
}Example: withSheetDestinations modifier
extension View {
func withSheetDestinations(
sheet: Binding<SheetDestination?>
) -> some View {
sheet(item: sheet) { destination in
Group {
switch destination {
case .composer:
ComposerView()
case .editProfile:
EditProfileView()
case .settings:
SettingsView()
case .report(let itemID):
ReportView(itemID: itemID)
}
}
}
}
}Example: presenting from a child view
struct StatusRow: View {
@Environment(RouterPath.self) private var router
var body: some View {
Button("Report") {
router.presentedSheet = .report(itemID: "123")
}
}
}Required wiring
For the child view to work, a parent view must:
- own the router instance,
- attach
withSheetDestinations(sheet: $router.presentedSheet)(or an equivalentsheet(item:)handler), and - inject it with
.environment(router)after the sheet modifier so the modal content inherits it.
This makes the child assignment to router.presentedSheet drive presentation at the root.
Example: sheets that need their own navigation
Wrap sheet content in a NavigationStack so it can push within the modal.
struct NavigationSheet<Content: View>: View {
var content: () -> Content
var body: some View {
NavigationStack {
content()
.toolbar { CloseToolbarItem() }
}
}
}Example: sheet owns its actions
Keep dismissal and confirmation logic inside the sheet when the actions belong to the modal itself.
struct EditItemSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(Store.self) private var store
let item: Item
@State private var isSaving = false
var body: some View {
VStack {
Button(isSaving ? "Saving..." : "Save") {
Task { await save() }
}
}
}
private func save() async {
isSaving = true
await store.save(item)
dismiss()
}
}Design choices to keep
- Centralize sheet routing so features can present modals without wiring bindings through many layers.
- Use
sheet(item:)to guarantee a single sheet is active and to drive presentation from the enum. - Group related sheets under the same
idwhen they are mutually exclusive (e.g., editor flows). - Keep sheet views lightweight and composed from smaller views; avoid large monoliths.
- Let sheets own their actions and call
dismiss()internally instead of forwardingonCanceloronConfirmclosures through many layers.
Pitfalls
- Avoid mixing
sheet(isPresented:)andsheet(item:)for the same concern; prefer a single enum. - Avoid
if letinside a sheet body when the presentation state already carries the selected model; prefersheet(item:). - Do not store heavy state inside
SheetDestination; pass lightweight identifiers or models. - If multiple sheets can appear from the same screen, give them distinct
idvalues.
Top bar overlays (iOS 26+ and fallback)
Intent
Provide a custom top selector or pill row that sits above scroll content, using safeAreaBar(.top) on iOS 26 and a compatible fallback on earlier OS versions.
iOS 26+ approach
Use safeAreaBar(edge: .top) to attach the view to the safe area bar.
if #available(iOS 26.0, *) {
content
.safeAreaBar(edge: .top) {
TopSelectorView()
.padding(.horizontal, .layoutPadding)
}
}Fallback for earlier iOS
Use .safeAreaInset(edge: .top) and hide the toolbar background to avoid double layers.
content
.toolbarBackground(.hidden, for: .navigationBar)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
TopSelectorView()
.padding(.vertical, 8)
.padding(.horizontal, .layoutPadding)
.background(Color.primary.opacity(0.06))
.background(Material.ultraThin)
Divider()
}
}Design choices to keep
- Use
safeAreaBarwhen available; it integrates better with the navigation bar. - Use a subtle background + divider in the fallback to keep separation from content.
- Keep the selector height compact to avoid pushing content too far down.
Pitfalls
- Don’t stack multiple top insets; it can create extra padding.
- Avoid heavy, opaque backgrounds that fight the navigation bar.