Files
gh-glittercowboy-taches-cc-…/skills/expertise/macos-apps/references/swiftui-patterns.md
2025-11-29 18:28:37 +08:00

23 KiB

Modern SwiftUI patterns for macOS apps. Covers @Bindable usage, navigation (NavigationSplitView, NavigationStack), windows, toolbars, menus, lists/tables, forms, sheets/alerts, drag & drop, focus management, and keyboard shortcuts. Reference sections: - observation_rules - @Bindable, @Observable, environment patterns - navigation - NavigationSplitView, NavigationStack, drill-down - windows - WindowGroup, Settings, auxiliary windows - toolbar - Toolbar items, customizable toolbars - menus - App commands, context menus - lists_and_tables - List selection, Table, OutlineGroup - forms - Settings forms, validation - sheets_and_alerts - Sheets, confirmation dialogs, file dialogs - drag_and_drop - Draggable items, drop targets, reorderable lists - focus_and_keyboard - Focus state, keyboard shortcuts - previews - Preview patterns

<observation_rules> <passing_model_objects> Critical rule for SwiftData @Model objects: Use @Bindable when the child view needs to observe property changes or create bindings. Use let only for static display.

// CORRECT: Use @Bindable when observing changes or binding
struct CardView: View {
    @Bindable var card: Card  // Use this for @Model objects

    var body: some View {
        VStack {
            TextField("Title", text: $card.title)  // Binding works
            Text(card.description)  // Observes changes
        }
    }
}

// WRONG: Using let breaks observation
struct CardViewBroken: View {
    let card: Card  // Won't observe property changes!

    var body: some View {
        Text(card.title)  // May not update when card.title changes
    }
}

</passing_model_objects>

<when_to_use_bindable> Use @Bindable when:

  • Passing @Model objects to child views that observe changes
  • Creating bindings to model properties ($model.property)
  • The view should update when model properties change

Use let when:

  • Passing simple value types (structs, enums)
  • The view only needs the value at the moment of creation
  • You explicitly don't want reactivity
// @Model objects - use @Bindable
struct ColumnView: View {
    @Bindable var column: Column  // SwiftData model

    var body: some View {
        VStack {
            Text(column.name)  // Updates when column.name changes
            ForEach(column.cards) { card in
                CardView(card: card)  // Pass model, use @Bindable in CardView
            }
        }
    }
}

// Value types - use let
struct BadgeView: View {
    let count: Int  // Value type, let is fine

    var body: some View {
        Text("\(count)")
    }
}

</when_to_use_bindable>

<environment_to_bindable> When accessing @Observable from environment, create local @Bindable for bindings:

struct SidebarView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        // Create local @Bindable for bindings
        @Bindable var appState = appState

        List(appState.items, selection: $appState.selectedID) { item in
            Text(item.name)
        }
    }
}

</environment_to_bindable> </observation_rules>

Standard three-column layout:
struct ContentView: View {
    @State private var selectedFolder: Folder?
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            // Sidebar
            SidebarView(selection: $selectedFolder)
        } content: {
            // Content list
            if let folder = selectedFolder {
                ItemListView(folder: folder, selection: $selectedItem)
            } else {
                ContentUnavailableView("Select a Folder", systemImage: "folder")
            }
        } detail: {
            // Detail
            if let item = selectedItem {
                DetailView(item: item)
            } else {
                ContentUnavailableView("Select an Item", systemImage: "doc")
            }
        }
        .navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 300)
    }
}

</navigation_split_view>

<two_column_layout>

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

    var body: some View {
        NavigationSplitView {
            SidebarView(selection: $selectedItem)
                .navigationSplitViewColumnWidth(min: 200, ideal: 250)
        } detail: {
            if let item = selectedItem {
                DetailView(item: item)
            } else {
                ContentUnavailableView("No Selection", systemImage: "sidebar.left")
            }
        }
    }
}

</two_column_layout>

<navigation_stack> For drill-down navigation:

struct BrowseView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            CategoryListView()
                .navigationDestination(for: Category.self) { category in
                    ItemListView(category: category)
                }
                .navigationDestination(for: Item.self) { item in
                    DetailView(item: item)
                }
        }
    }
}

</navigation_stack>

```swift @main struct MyApp: App { var body: some Scene { // Main window WindowGroup { ContentView() } .commands { AppCommands() }
    // Auxiliary window
    Window("Inspector", id: "inspector") {
        InspectorView()
    }
    .windowResizability(.contentSize)
    .defaultPosition(.trailing)
    .keyboardShortcut("i", modifiers: [.command, .option])

    // Utility window
    Window("Quick Entry", id: "quick-entry") {
        QuickEntryView()
    }
    .windowStyle(.hiddenTitleBar)
    .windowResizability(.contentSize)

    // Settings
    Settings {
        SettingsView()
    }
}

}

</multiple_window_types>

<window_control>
Open windows programmatically:

```swift
struct ContentView: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Show Inspector") {
            openWindow(id: "inspector")
        }
    }
}

</window_control>

<document_group> For document-based apps:

@main
struct MyApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: MyDocument()) { file in
            DocumentView(document: file.$document)
        }
        .commands {
            DocumentCommands()
        }
    }
}

</document_group>

<debugging_swiftui_appkit> Meta-principle: Declarative overrides Imperative

When SwiftUI wraps AppKit (via NSHostingView, NSViewRepresentable, etc.), SwiftUI's declarative layer manages the AppKit objects underneath. Your AppKit code may be "correct" but irrelevant if SwiftUI is controlling that concern.

Debugging pattern:

  1. Issue occurs (e.g., window won't respect constraints, focus not working, layout broken)
  2. Wrong approach: Jump to AppKit APIs to "fix" it imperatively
  3. Right approach: Check SwiftUI layer first - what's declaratively controlling this?
  4. Why: The wrapper controls the wrapped. Higher abstraction wins.

Example scenario - Window sizing:

  • Symptom: NSWindow.minSize code runs but window still resizes smaller
  • Wrong: Add more AppKit code, observers, notifications to "force" it
  • Right: Search codebase for .frame(minWidth:) on content view - that's what's actually controlling it
  • Lesson: NSHostingView manages window constraints based on SwiftUI content

This pattern applies broadly:

  • Window sizing → Check .frame(), .windowResizability() before NSWindow properties
  • Focus management → Check @FocusState, .focused() before NSResponder chain
  • Layout constraints → Check SwiftUI layout modifiers before Auto Layout
  • Toolbar → Check .toolbar {} before NSToolbar setup

When to actually use AppKit: Only when SwiftUI doesn't provide the capability (custom drawing, specialized controls, backward compatibility). Not as a workaround when SwiftUI "doesn't work" - you probably haven't found SwiftUI's way yet. </debugging_swiftui_appkit>

```swift struct ContentView: View { @State private var searchText = ""
var body: some View {
    NavigationSplitView {
        SidebarView()
    } detail: {
        DetailView()
    }
    .toolbar {
        ToolbarItemGroup(placement: .primaryAction) {
            Button(action: addItem) {
                Label("Add", systemImage: "plus")
            }

            Button(action: deleteItem) {
                Label("Delete", systemImage: "trash")
            }
        }

        ToolbarItem(placement: .navigation) {
            Button(action: toggleSidebar) {
                Label("Toggle Sidebar", systemImage: "sidebar.left")
            }
        }
    }
    .searchable(text: $searchText, placement: .toolbar)
}

private func toggleSidebar() {
    NSApp.keyWindow?.firstResponder?.tryToPerform(
        #selector(NSSplitViewController.toggleSidebar(_:)),
        with: nil
    )
}

}

</toolbar_content>

<customizable_toolbar>
```swift
struct ContentView: View {
    var body: some View {
        MainContent()
            .toolbar(id: "main") {
                ToolbarItem(id: "add", placement: .primaryAction) {
                    Button(action: add) {
                        Label("Add", systemImage: "plus")
                    }
                }

                ToolbarItem(id: "share", placement: .secondaryAction) {
                    ShareLink(item: currentItem)
                }

                ToolbarItem(id: "spacer", placement: .automatic) {
                    Spacer()
                }
            }
            .toolbarRole(.editor)
    }
}

</customizable_toolbar>

```swift struct AppCommands: Commands { @Environment(\.openWindow) private var openWindow
var body: some Commands {
    // Replace standard menu items
    CommandGroup(replacing: .newItem) {
        Button("New Project") {
            // Create new project
        }
        .keyboardShortcut("n", modifiers: .command)
    }

    // Add new menu
    CommandMenu("View") {
        Button("Show Inspector") {
            openWindow(id: "inspector")
        }
        .keyboardShortcut("i", modifiers: [.command, .option])

        Divider()

        Button("Zoom In") {
            // Zoom in
        }
        .keyboardShortcut("+", modifiers: .command)

        Button("Zoom Out") {
            // Zoom out
        }
        .keyboardShortcut("-", modifiers: .command)
    }

    // Add to existing menu
    CommandGroup(after: .sidebar) {
        Button("Toggle Inspector") {
            // Toggle
        }
        .keyboardShortcut("i", modifiers: .command)
    }
}

}

</app_commands>

<context_menus>
```swift
struct ItemRow: View {
    let item: Item
    let onDelete: () -> Void
    let onDuplicate: () -> Void

    var body: some View {
        HStack {
            Text(item.name)
            Spacer()
        }
        .contextMenu {
            Button("Duplicate") {
                onDuplicate()
            }

            Button("Delete", role: .destructive) {
                onDelete()
            }

            Divider()

            Menu("Move to") {
                ForEach(folders) { folder in
                    Button(folder.name) {
                        move(to: folder)
                    }
                }
            }
        }
    }
}

</context_menus>

<lists_and_tables> <list_selection>

struct SidebarView: View {
    @Environment(AppState.self) private var appState

    var body: some View {
        @Bindable var appState = appState

        List(appState.items, selection: $appState.selectedItemID) { item in
            Label(item.name, systemImage: item.icon)
                .tag(item.id)
        }
        .listStyle(.sidebar)
    }
}

</list_selection>

```swift struct ItemTableView: View { @Environment(AppState.self) private var appState @State private var sortOrder = [KeyPathComparator(\Item.name)]
var body: some View {
    @Bindable var appState = appState

    Table(appState.items, selection: $appState.selectedItemIDs, sortOrder: $sortOrder) {
        TableColumn("Name", value: \.name) { item in
            Text(item.name)
        }

        TableColumn("Date", value: \.createdAt) { item in
            Text(item.createdAt.formatted(date: .abbreviated, time: .shortened))
        }
        .width(min: 100, ideal: 150)

        TableColumn("Size", value: \.size) { item in
            Text(ByteCountFormatter.string(fromByteCount: item.size, countStyle: .file))
        }
        .width(80)
    }
    .onChange(of: sortOrder) {
        appState.items.sort(using: sortOrder)
    }
}

}

</table>

<outline_group>
For hierarchical data:

```swift
struct OutlineView: View {
    let rootItems: [TreeItem]

    var body: some View {
        List {
            OutlineGroup(rootItems, children: \.children) { item in
                Label(item.name, systemImage: item.icon)
            }
        }
    }
}

struct TreeItem: Identifiable {
    let id = UUID()
    var name: String
    var icon: String
    var children: [TreeItem]?
}

</outline_group> </lists_and_tables>

```swift struct SettingsView: View { @AppStorage("autoSave") private var autoSave = true @AppStorage("saveInterval") private var saveInterval = 5 @AppStorage("theme") private var theme = "system"
var body: some View {
    Form {
        Section("General") {
            Toggle("Auto-save documents", isOn: $autoSave)

            if autoSave {
                Stepper("Save every \(saveInterval) minutes", value: $saveInterval, in: 1...60)
            }
        }

        Section("Appearance") {
            Picker("Theme", selection: $theme) {
                Text("System").tag("system")
                Text("Light").tag("light")
                Text("Dark").tag("dark")
            }
            .pickerStyle(.radioGroup)
        }
    }
    .formStyle(.grouped)
    .frame(width: 400)
    .padding()
}

}

</settings_form>

<validation>
```swift
struct EditItemView: View {
    @Binding var item: Item
    @State private var isValid = true

    var body: some View {
        Form {
            TextField("Name", text: $item.name)
                .onChange(of: item.name) {
                    isValid = !item.name.isEmpty
                }

            if !isValid {
                Text("Name is required")
                    .foregroundStyle(.red)
                    .font(.caption)
            }
        }
    }
}

<sheets_and_alerts>

struct ContentView: View {
    @State private var showingSheet = false
    @State private var itemToEdit: Item?

    var body: some View {
        MainContent()
            .sheet(isPresented: $showingSheet) {
                SheetContent()
            }
            .sheet(item: $itemToEdit) { item in
                EditItemView(item: item)
            }
    }
}

<confirmation_dialog>

struct ItemRow: View {
    let item: Item
    @State private var showingDeleteConfirmation = false

    var body: some View {
        Text(item.name)
            .confirmationDialog(
                "Delete \(item.name)?",
                isPresented: $showingDeleteConfirmation,
                titleVisibility: .visible
            ) {
                Button("Delete", role: .destructive) {
                    deleteItem()
                }
            } message: {
                Text("This action cannot be undone.")
            }
    }
}

</confirmation_dialog>

<file_dialogs>

struct ContentView: View {
    @State private var showingImporter = false
    @State private var showingExporter = false

    var body: some View {
        VStack {
            Button("Import") {
                showingImporter = true
            }
            Button("Export") {
                showingExporter = true
            }
        }
        .fileImporter(
            isPresented: $showingImporter,
            allowedContentTypes: [.json, .plainText],
            allowsMultipleSelection: true
        ) { result in
            switch result {
            case .success(let urls):
                importFiles(urls)
            case .failure(let error):
                handleError(error)
            }
        }
        .fileExporter(
            isPresented: $showingExporter,
            document: exportDocument,
            contentType: .json,
            defaultFilename: "export.json"
        ) { result in
            // Handle result
        }
    }
}

</file_dialogs> </sheets_and_alerts>

<drag_and_drop>

struct DraggableItem: View {
    let item: Item

    var body: some View {
        Text(item.name)
            .draggable(item.id.uuidString) {
                // Preview
                Label(item.name, systemImage: item.icon)
                    .padding()
                    .background(.regularMaterial)
                    .cornerRadius(8)
            }
    }
}

<drop_target>

struct DropTargetView: View {
    @State private var isTargeted = false

    var body: some View {
        Rectangle()
            .fill(isTargeted ? Color.accentColor.opacity(0.3) : Color.clear)
            .dropDestination(for: String.self) { items, location in
                for itemID in items {
                    handleDrop(itemID)
                }
                return true
            } isTargeted: { targeted in
                isTargeted = targeted
            }
    }
}

</drop_target>

<reorderable_list>

struct ReorderableList: View {
    @State private var items = ["A", "B", "C", "D"]

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text(item)
            }
            .onMove { from, to in
                items.move(fromOffsets: from, toOffset: to)
            }
        }
    }
}

</reorderable_list> </drag_and_drop>

<focus_and_keyboard> <focus_state>

struct EditForm: View {
    @State private var name = ""
    @State private var description = ""
    @FocusState private var focusedField: Field?

    enum Field {
        case name, description
    }

    var body: some View {
        Form {
            TextField("Name", text: $name)
                .focused($focusedField, equals: .name)

            TextField("Description", text: $description)
                .focused($focusedField, equals: .description)
        }
        .onSubmit {
            switch focusedField {
            case .name:
                focusedField = .description
            case .description:
                save()
            case nil:
                break
            }
        }
        .onAppear {
            focusedField = .name
        }
    }
}

</focus_state>

<keyboard_shortcuts> CRITICAL: Menu commands required for reliable keyboard shortcuts

.onKeyPress() handlers ALONE are unreliable in SwiftUI. You MUST define menu commands with .keyboardShortcut() for keyboard shortcuts to work properly.

<correct_pattern> Step 1: Define menu command in App or WindowGroup:

struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .commands {
            CommandMenu("Edit") {
                EditLoopButton()
                Divider()
                DeleteButton()
            }
        }
    }
}

// Menu command buttons with keyboard shortcuts
struct EditLoopButton: View {
    @FocusedValue(\.selectedItem) private var selectedItem

    var body: some View {
        Button("Edit Item") {
            // Perform action
        }
        .keyboardShortcut("e", modifiers: [])
        .disabled(selectedItem == nil)
    }
}

struct DeleteButton: View {
    @FocusedValue(\.selectedItem) private var selectedItem

    var body: some View {
        Button("Delete Item") {
            // Perform deletion
        }
        .keyboardShortcut(.delete, modifiers: [])
        .disabled(selectedItem == nil)
    }
}

Step 2: Expose state via FocusedValues:

// Define focused value keys
struct SelectedItemKey: FocusedValueKey {
    typealias Value = Binding<Item?>
}

extension FocusedValues {
    var selectedItem: Binding<Item?>? {
        get { self[SelectedItemKey.self] }
        set { self[SelectedItemKey.self] = newValue }
    }
}

// In your view, expose the state
struct ContentView: View {
    @State private var selectedItem: Item?

    var body: some View {
        ItemList(selection: $selectedItem)
            .focusedSceneValue(\.selectedItem, $selectedItem)
    }
}

Why menu commands are required:

  • .keyboardShortcut() on menu buttons registers shortcuts at the system level
  • .onKeyPress() alone only works when the view hierarchy receives events
  • System menus (Edit, View, etc.) can intercept keys before .onKeyPress() fires
  • Menu commands show shortcuts in the menu bar for discoverability

</correct_pattern>

<onKeyPress_usage> When to use .onKeyPress():

Use for keyboard input (typing, arrow keys for navigation):

struct ContentView: View {
    @FocusState private var isInputFocused: Bool

    var body: some View {
        MainContent()
            .onKeyPress(.upArrow) {
                guard !isInputFocused else { return .ignored }
                selectPrevious()
                return .handled
            }
            .onKeyPress(.downArrow) {
                guard !isInputFocused else { return .ignored }
                selectNext()
                return .handled
            }
            .onKeyPress(characters: .alphanumerics) { press in
                guard !isInputFocused else { return .ignored }
                handleTypeahead(press.characters)
                return .handled
            }
    }
}

Always check focus state to prevent interfering with text input. </onKeyPress_usage> </keyboard_shortcuts> </focus_and_keyboard>

```swift #Preview("Default") { ContentView() .environment(AppState()) }

#Preview("With Data") { let state = AppState() state.items = [ Item(name: "First"), Item(name: "Second") ]

return ContentView()
    .environment(state)

}

#Preview("Dark Mode") { ContentView() .environment(AppState()) .preferredColorScheme(.dark) }

#Preview(traits: .fixedLayout(width: 800, height: 600)) { ContentView() .environment(AppState()) }

</previews>