commit 4ec01363a6ab1627d3a8c04dfd3cdc3a52f60098
parent 62e3b387afa076f761f8d2e69ca1c95d1f19d94b
Author: Michael Camilleri <[email protected]>
Date: Thu, 5 Mar 2026 03:58:55 +0900
Fix keyboard navigation bugs in iOS version
This commit waves a white flag and gives up trying to use SwiftUI for
certain keyboard navigation elements. Instead, it uses the kludge of a
UIViewRepresentible object to intercept certain keys so that they can
be handled appropriately.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
4 files changed, 108 insertions(+), 15 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -140,13 +140,14 @@
- **Drag-and-drop reordering**: Both platforms maintain `visualOrder` during drag for live preview and commit to Core Data via `store.moveTask()` on release; keyboard navigation uses `displayActiveTasks` (visual order during drag, data order otherwise)
- **macOS**: `onDrag` + `dropDestination` with `isTargeted` callbacks; three-zone overlay on each row (1/6 top, 2/3 middle, 1/6 bottom) using `.layoutPriority()` for proportional sizing; top zone always inserts before, bottom always after, middle uses smart direction logic; VStack catch-all `dropDestination` handles the actual drop; ScrollView-level `.onDrop` catch-all clears drag state when a drag is cancelled without a successful drop; 1×1 transparent drag preview; visual lift (scale + shadow + zIndex) triggers on hold (0.4s) via `liftedTaskID` before the drag session begins — `LongPressGesture` in `TaskRowDragGesture` fires `onLift`, an `NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp)` monitor clears the lift on release without drag; once `.onDrag` fires, `draggedTaskID` takes over and `liftedTaskID` is cleared; `isRowLifted()` combines both states for the visual modifiers; note that `leftMouseUp` does not fire during system drag sessions (macOS enters a modal event loop), so cancelled-drag cleanup relies on the ScrollView catch-all `.onDrop`; all of this lives directly in the macOS `body` in `ListlessMac/Views/TaskListView.swift` and `ListlessMac/Helpers/TaskRowDragGesture.swift`
- **iOS**: `LongPressGesture(minimumDuration: 0.4).sequenced(before: DragGesture(...))` via `.simultaneousGesture()`; dragged row stays in-place with scale/shadow/zIndex lift (no overlay or ghost spacer); row frames tracked via `.onGeometryChange` (global coords); neighbour-swap when finger crosses 20% of row height past its own edge; `visualOrder` updated with spring animation; `handleIOSDragChanged` and `commitIOSDrag` live in `ListlessiOS/Extensions/TaskListView+Drag.swift`; `didStartDrag()` (sets `isDragging`, triggers haptic) is defined on the iOS struct in `ListlessiOS/Views/TaskListView.swift` with an empty no-op counterpart on the macOS struct
-- **Keyboard navigation system**: Dictionary-based keybindings in `Listless/Helpers/KeyboardNavigationModifier.swift`
+- **Keyboard navigation system (macOS)**: Dictionary-based keybindings in `Listless/Helpers/KeyboardNavigationModifier.swift`
- `ShortcutKey` struct combines `KeyEquivalent` + `EventModifiers` for flexible shortcuts
- Manual Hashable conformance required (EventModifiers doesn't auto-conform)
- Key normalization: backspace character `\u{7F}` → `.delete` for consistent matching
- Modifier normalization: strips `.function` and `.numericPad` (system modifiers that come with arrow keys)
- Only user-intentional modifiers (.command, .shift, .option, .control) are matched
- Supports modifier-based shortcuts like `ShortcutKey(key: "n", modifiers: .command)` for future ⌘N
+- **Keyboard navigation system (iOS)**: `KeyCommandBridge` (`ListlessiOS/Helpers/KeyCommandBridge.swift`) — a `UIViewRepresentable` wrapping a `UIView` that becomes first responder and registers `UIKeyCommand` entries (Up, Down, Space, Return, Delete) with `wantsPriorityOverSystemBehavior = true`. This bypasses iPadOS's `@FocusState` limitation where programmatic focus assignment to a `.focusable()` view silently fails with a hardware keyboard, causing `.onKeyPress` to never fire. The bridge is active when not editing a task (`focusedFieldBinding` is not `.task(...)`); `fState.focusedField` is set to `.scrollView` directly in `onAppear` so navigation function guards pass. The iOS `TaskListView` body does **not** use `.focusable()`, `.focused($focusedFieldBinding, equals: .scrollView)`, or `.keyboardNavigation()` — those remain on macOS only.
- Avoid `Spacer` inside `ScrollView` (causes unwanted scrollbar when content fits viewport).
- **Escape hatches** (`GeometryReader`, `PreferenceKey` size reporting, `UIViewRepresentable`/`NSViewRepresentable`): only reach for these after exhausting SwiftUI-native alternatives. They add complexity, can cause layout loops, and make state flow harder to follow. If you find yourself about to use one, first try `.frame`, `.overlay`, `.background`, `alignmentGuide`, or a different view decomposition.
diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj
@@ -12,6 +12,7 @@
0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBB8A3BEB346267B30B4675F /* TaskItem.swift */; };
0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */; };
11AA75BE98CFBE44AEAB7100 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9404C09EE1A4D91DFF338464 /* Media.xcassets */; };
+ 12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */; };
1477B460E3DEFA3FDB7DA65B /* TaskListTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F9754B32B2FD5AF8552BC85 /* TaskListTypes.swift */; };
172F2DD978988E207610055F /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */; };
182D9FB61E3A3650D7D83D2A /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067D666DF0AF1C53404ECF7C /* TaskRowSwipeGesture.swift */; };
@@ -147,6 +148,7 @@
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
E51B8129962E5CC78ECDDC2B /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; };
E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; };
+ E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCommandBridge.swift; sourceTree = "<group>"; };
E9288507CE6023425D1DE724 /* SyncDiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncDiagnosticsView.swift; sourceTree = "<group>"; };
F1E998119283F784B9ADEE28 /* AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppColors.swift; sourceTree = "<group>"; };
F416DD868A4C044F0D64F8D0 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; };
@@ -161,6 +163,7 @@
F1E998119283F784B9ADEE28 /* AppColors.swift */,
46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */,
B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */,
+ E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */,
FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */,
E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */,
6ECE0E961F87BA32FA87BF90 /* TappableTextField.swift */,
@@ -526,6 +529,7 @@
CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */,
889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */,
E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */,
+ 12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */,
19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */,
F6587B84ECC6BFE92A5FB493 /* KeyboardNavigationModifier.swift in Sources */,
5B60B409CE4BA668DB30A65D /* Listless.xcdatamodeld in Sources */,
diff --git a/ListlessiOS/Helpers/KeyCommandBridge.swift b/ListlessiOS/Helpers/KeyCommandBridge.swift
@@ -0,0 +1,90 @@
+import SwiftUI
+import UIKit
+
+/// UIViewRepresentable that captures keyboard input via UIKeyCommand when
+/// SwiftUI's `.focusable()` / `@FocusState` system fails to accept
+/// programmatic focus (known iPadOS limitation with hardware keyboards).
+/// On iPhone, where `@FocusState` works normally, `isActive` stays false
+/// and the bridge remains inert.
+struct KeyCommandBridge: UIViewRepresentable {
+ let isActive: Bool
+ let onUp: () -> Void
+ let onDown: () -> Void
+ let onSpace: () -> Void
+ let onReturn: () -> Void
+ let onDelete: () -> Void
+
+ func makeUIView(context: Context) -> KeyCaptureView {
+ let view = KeyCaptureView()
+ view.onUp = onUp
+ view.onDown = onDown
+ view.onSpace = onSpace
+ view.onReturn = onReturn
+ view.onDelete = onDelete
+ view.isActive = isActive
+ return view
+ }
+
+ func updateUIView(_ view: KeyCaptureView, context: Context) {
+ view.onUp = onUp
+ view.onDown = onDown
+ view.onSpace = onSpace
+ view.onReturn = onReturn
+ view.onDelete = onDelete
+ view.isActive = isActive
+
+ if isActive && !view.isFirstResponder {
+ DispatchQueue.main.async { [weak view] in
+ guard let view, view.isActive else { return }
+ view.becomeFirstResponder()
+ }
+ }
+ }
+
+ final class KeyCaptureView: UIView {
+ var isActive = false
+ var onUp: (() -> Void)?
+ var onDown: (() -> Void)?
+ var onSpace: (() -> Void)?
+ var onReturn: (() -> Void)?
+ var onDelete: (() -> Void)?
+
+ override var canBecomeFirstResponder: Bool { true }
+
+ override var keyCommands: [UIKeyCommand]? {
+ guard isActive else { return nil }
+ return [
+ UIKeyCommand.inputUpArrow,
+ UIKeyCommand.inputDownArrow,
+ " ",
+ "\r",
+ "\u{8}",
+ ].map { input in
+ let cmd = UIKeyCommand(
+ input: input,
+ modifierFlags: [],
+ action: #selector(handleKeyCommand(_:))
+ )
+ cmd.wantsPriorityOverSystemBehavior = true
+ return cmd
+ }
+ }
+
+ @objc private func handleKeyCommand(_ sender: UIKeyCommand) {
+ switch sender.input {
+ case UIKeyCommand.inputUpArrow:
+ onUp?()
+ case UIKeyCommand.inputDownArrow:
+ onDown?()
+ case " ":
+ onSpace?()
+ case "\r":
+ onReturn?()
+ case "\u{8}":
+ onDelete?()
+ default:
+ break
+ }
+ }
+ }
+}
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -164,22 +164,20 @@ struct TaskListView: View, TaskListViewProtocol {
.onTapGesture {
handleBackgroundTap()
}
- .focusable()
- .focused($focusedFieldBinding, equals: .scrollView)
- .focusEffectDisabled()
.accessibilityIdentifier("task-list-scrollview")
- .keyboardNavigation([
- ShortcutKey(key: .upArrow): navigateUp,
- ShortcutKey(key: .downArrow): navigateDown,
- ShortcutKey(key: .space): toggleSelectedTask,
- ShortcutKey(key: .return): focusSelectedTask,
- ShortcutKey(key: .delete): deleteSelectedTask,
- ])
+ .background {
+ let isEditing = if case .task = focusedFieldBinding { true } else { false }
+ KeyCommandBridge(
+ isActive: !isEditing,
+ onUp: { _ = navigateUp() },
+ onDown: { _ = navigateDown() },
+ onSpace: { _ = toggleSelectedTask() },
+ onReturn: { _ = focusSelectedTask() },
+ onDelete: { _ = deleteSelectedTask() }
+ )
+ }
.onAppear {
- if focusedFieldBinding == nil {
- focusedFieldBinding = .scrollView
- }
- fState.focusedField = focusedFieldBinding
+ fState.focusedField = .scrollView
}
.onChange(of: undoManager, initial: true) { _, newValue in
managedObjectContext.undoManager = newValue