Platform Copy Paste
Status: ready
Symptom
The iPhone screen ships to iPad, Mac or Catalyst unchanged: same gestures, no menu commands, no keyboard path, no table semantics.
Action: reorder an item in a saved list
iPhone: drag handle, works
iPad + keyboard: no command
Mac Catalyst: no menu item, no shortcut
The shared code compiles. The task doesn't travel.
Where it shows up
Catalyst apps, iPad sidebars, Mac ports, shared SwiftUI components, list-detail screens, transaction tables, order history, media libraries.
Why it blocks the task
Each platform has its own input. A Mac user expects the menu bar, keyboard shortcuts, a visible focus ring and tables that VoiceOver reads by row and column. An iPad user with a Magic Keyboard expects Tab to move focus and ⌘ shortcuts to work. A swipe-only action is unreachable from a keyboard. VoiceOver on Mac is a different review from VoiceOver on iPhone.
Root cause
UI is shared without deciding how each platform reaches each task.
- The touch path is treated as universal.
- A swipe is the only row action.
- No menu commands.
- The keyboard focus path is incomplete.
- Hover carries meaning with no fallback.
- The focus ring is hidden for visual polish.
- iPad QA is counted as Mac QA.
Fix
Verdict: one task, one path per platform. Write the paths down per task, then test each one on its platform.
Why: the task stays the same, the input changes. Without a written path per platform, the Mac version inherits iPhone gestures by default.
Task: reorder an item
iPhone: drag + "Move up" / "Move down" accessibility actions
iPad: Edit mode, context menu, ⌘↑ / ⌘↓
Mac: menu command, row context menu, ⌘↑ / ⌘↓
SwiftUI menu commands go on the scene. The selected item comes in through @FocusedValue:
@main
struct LeafApp: App {
var body: some Scene {
WindowGroup { ContentView() }
.commands { OrderCommands() }
}
}
struct OrderCommands: Commands {
@FocusedValue(\.selectedOrder) private var order
var body: some Commands {
CommandMenu("Order") {
Button("Reorder") { order?.reorder() }
.keyboardShortcut("r")
.disabled(order == nil)
}
}
}
The view publishes the selection with .focusedSceneValue(\.selectedOrder, selection). Full example: Chapter 12.
A frequent action on iPad also gets a shortcut:
Button("Archive message") { archive(message) }
.keyboardShortcut(.delete, modifiers: .command)
Data that's a table on Mac is a Table, not a stack of HStacks. VoiceOver reads a Table by row and column.
What we don't do
- We don't hide the focus ring for a cleaner look.
- We don't count an iPad check as a Mac check.
Check
Profile: iPad with keyboard, Full Keyboard Access on
Task: archive a message, reorder an item, open details
Expected: Tab reaches each row and action
each action works without touch
the focus ring is visible
Profile: Mac, VoiceOver (⌘F5)
Task: reorder from order history
Expected: "Reorder" is in the menu bar and works with ⌘R
rows are read with their values
Profile: iPad Split View, narrow and wide
Task: the same flow
Expected: no action disappears in the narrow layout
WCAG mapping
2.1.1 Keyboard: every task works without a pointer gesture.2.4.3 Focus Order: focus order holds across layouts.2.4.7 Focus Visible: the focus indicator is visible.4.1.2 Name, Role, Value: platform controls expose role and state.