906 lines
23 KiB
Markdown
906 lines
23 KiB
Markdown
<overview>
|
|
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.
|
|
</overview>
|
|
|
|
<sections>
|
|
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
|
|
</sections>
|
|
|
|
<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.
|
|
|
|
```swift
|
|
// 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
|
|
|
|
```swift
|
|
// @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:
|
|
|
|
```swift
|
|
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>
|
|
|
|
<navigation>
|
|
<navigation_split_view>
|
|
Standard three-column layout:
|
|
|
|
```swift
|
|
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>
|
|
```swift
|
|
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:
|
|
|
|
```swift
|
|
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>
|
|
</navigation>
|
|
|
|
<windows>
|
|
<multiple_window_types>
|
|
```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:
|
|
|
|
```swift
|
|
@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>
|
|
</windows>
|
|
|
|
<toolbar>
|
|
<toolbar_content>
|
|
```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>
|
|
</toolbar>
|
|
|
|
<menus>
|
|
<app_commands>
|
|
```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>
|
|
</menus>
|
|
|
|
<lists_and_tables>
|
|
<list_selection>
|
|
```swift
|
|
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>
|
|
|
|
<table>
|
|
```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>
|
|
|
|
<forms>
|
|
<settings_form>
|
|
```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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
</validation>
|
|
</forms>
|
|
|
|
<sheets_and_alerts>
|
|
<sheet>
|
|
```swift
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
</sheet>
|
|
|
|
<confirmation_dialog>
|
|
```swift
|
|
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>
|
|
```swift
|
|
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>
|
|
<draggable>
|
|
```swift
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
</draggable>
|
|
|
|
<drop_target>
|
|
```swift
|
|
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>
|
|
```swift
|
|
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>
|
|
```swift
|
|
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:**
|
|
|
|
```swift
|
|
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:**
|
|
|
|
```swift
|
|
// 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):
|
|
|
|
```swift
|
|
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>
|
|
|
|
<previews>
|
|
```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>
|