Back to .md Directory

UI Patterns

Defines SwiftUI dialog and sheet constraints for game-controller keyboard shortcut support, plus state-timing workarounds.

May 2, 2026
0 downloads
1 views
ai eval
View source

What this file does

Defines SwiftUI dialog and sheet constraints for game-controller keyboard shortcut support, plus state-timing workarounds.

When to use it

  • Building a macOS SwiftUI app with game controller input
  • Using modal dialogs that must support Escape and Cmd+Return
  • Encountering blank sheets due to stale state in.onAppear
  • Chaining sheet presentations without simultaneous booleans

Assumes this stack

SwiftUImacOS

UI Patterns

Constraints and patterns for SwiftUI views that resist unit testing.


Every dialog must support Escape and Cmd+Return

All sheets, alerts, and modal dialogs must have:

  • Escape to cancel/dismiss: .keyboardShortcut(.cancelAction) on the Cancel or Done button
  • Cmd+Return to save/confirm: .keyboardShortcut(.return, modifiers: .command) on the primary action button

Use .keyboardShortcut(.return, modifiers: .command) explicitly, not .keyboardShortcut(.defaultAction). The .defaultAction shortcut binds to plain Return, which conflicts with TextField input (pressing Return in a text field would trigger the save button instead of inserting a newline or submitting the field).

Why this matters: Users operate the app with a game controller. Controller buttons map to keyboard shortcuts. If a dialog doesn't respond to Escape or Cmd+Return, users get stuck in modal dialogs with no way out.

Why tests can't catch this: Keyboard shortcut registration is a SwiftUI modifier side effect. No unit test can verify that a .keyboardShortcut modifier is attached to a button.

Do not use native .alert() for text input

Native SwiftUI .alert() dialogs have unreliable Escape handling on macOS when they contain TextFields. Convert any alert with a TextField to a custom .sheet() with explicit Cancel and Save buttons that have keyboard shortcuts.

// BAD: native alert with text input
.alert("Rename", isPresented: $showing) {
    TextField("Name", text: $name)
    Button("Cancel", role: .cancel) { }
    Button("Rename") { save() }
}

// GOOD: custom sheet with keyboard shortcuts
.sheet(isPresented: $showing) {
    VStack {
        TextField("Name", text: $name)
        HStack {
            Button("Cancel") { dismiss() }
                .keyboardShortcut(.cancelAction)
            Button("Rename") { save(); dismiss() }
                .keyboardShortcut(.return, modifiers: .command)
        }
    }
}

Set state before presenting sheets

When a sheet depends on state (e.g., prefilling a form), set the state before triggering the sheet presentation. SwiftUI's .sheet(isPresented:) may evaluate its content closure before all state changes in the same render pass have propagated. If the sheet reads stale state in .onAppear, it will appear blank.

// BAD: state and presentation in the same synchronous block
prefilledData = data
showingSheet = true  // sheet may capture prefilledData as nil

// GOOD: defer presentation to the next render cycle
prefilledData = data
DispatchQueue.main.async {
    showingSheet = true
}

When dismissing one sheet to present another (e.g., gallery → editor), use onDismiss instead of setting both booleans simultaneously. SwiftUI cannot present two sheets from the same view at once.

// BAD: two sheets from the same view simultaneously
.sheet(isPresented: $showingGallery) {
    GalleryView { item in
        selectedItem = item
        showingEditor = true  // ignored: gallery sheet is still visible
    }
}

// GOOD: chain via onDismiss
.sheet(isPresented: $showingGallery, onDismiss: {
    if selectedItem != nil {
        showingEditor = true
    }
}) {
    GalleryView { item in
        selectedItem = item
        // gallery dismisses itself; onDismiss triggers editor
    }
}

Why tests can't catch this: The bug manifests only in the SwiftUI rendering pipeline's state propagation timing. Unit tests don't run the render loop.


Controller button display names respect controller type

When displaying button names in the UI, always use button.displayName(forDualSense:) with the current controller type. PlayStation controllers use different names (Cross/Circle/Square/Triangle vs A/B/X/Y). Hardcoding Xbox names breaks the experience for DualSense users.


Sheet width should accommodate content type

Sheets that contain a visual keyboard (KeyboardVisualView) should expand to ~750pt width when the keyboard is visible. Use .animation on the frame to smooth the transition.

Sheets with system command fields (webhooks, OBS, shell commands) need ~580pt. Standard sheets use ~520pt.

What's inside

3 main sections, 2 code examples for dialog shortcuts, 2 for state timing, 1 for sheet widths

Change this for your project

  • Replace KeyboardVisualView with your own keyboard view type
  • Replace DualSense in displayName(forDualSense:) with your controller type enum
  • Replace GalleryView and selectedItem with your own sheet and state names

Where it goes

Use as the system prompt for your model call, or as the base instructions in your agent framework.

Worth borrowing

  • Using .keyboardShortcut(.return, modifiers:.command) instead of .defaultAction to avoid TextField conflicts
  • Deferring sheet presentation with DispatchQueue.main.async to ensure state propagation
  • Chaining sheets via onDismiss instead of setting two booleans simultaneously

Related Documents