listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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:
MAGENTS.md | 3++-
MListless.xcodeproj/project.pbxproj | 4++++
AListlessiOS/Helpers/KeyCommandBridge.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskListView.swift | 26++++++++++++--------------
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