commit 6f3fb4de51ee5d8019d508c61a5eb72dddf596fa
parent e9d82013903eed2ccfe26366715179c9c65c0eab
Author: Michael Camilleri <[email protected]>
Date: Thu, 5 Mar 2026 05:07:03 +0900
Use workaround for menu items in iOS
At the risk of an overly broad generalisation, SwiftUI's Commands API
doesn't work reliably. This commit follows a similar approach on iOS to
how menus are handled in the macOS version of Listless.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
5 files changed, 203 insertions(+), 78 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -78,15 +78,25 @@
- SwiftUI's declarative patterns proved more maintainable for this app
- **Switching to AppKit** (if needed): Update `project.yml` to exclude SwiftUI views and include `ListlessMac/AppKit/`, then run `xcodegen generate`
-## macOS Menu Customisation
-- **Do not use SwiftUI's `Commands` API** — broken on macOS 15: dividers don't render and `CommandGroup(replacing:)` causes Window menu jitter.
+## Menu Customisation (both platforms)
+- **Do not use SwiftUI's `Commands` API** — broken on macOS 15 (dividers don't render, `CommandGroup(replacing:)` causes Window menu jitter) and on iPadOS (arrow key glyphs render as "?", `@FocusedValue` propagation fails when a UIKit view is first responder).
+- **Both platforms use native menu APIs** with a shared coordinator pattern: action closures + enabled-state booleans updated by `TaskListView.updateMenuCoordinator()`.
+- **Adding a new key binding** requires four touch points per platform: (1) coordinator — add action closure + enabled flag, (2) selector/action protocol — add the `@objc` method, (3) menu definition — register the key command in the right menu, (4) `updateMenuCoordinator()` — wire up the action and enabled state.
+
+### macOS
- **Menus are fully AppKit-owned** in `AppDelegate.installMainMenu()` (`ListlessMac/ListlessMacApp.swift`), assigned directly via `NSApp.mainMenu`.
- **No runtime menu patching**: do not use `NSMenu.didAddItemNotification`/tag-guard patch logic for command setup in the current architecture.
-- **`MenuCoordinator`** (`ListlessMac/Helpers/AppCommands.swift`) bridges SwiftUI state to AppKit: action closures + enabled-state booleans updated by `TaskListView.updateMenuCoordinator()`. Enabled state is surfaced via `NSMenuItemValidation.validateMenuItem` on `AppDelegate`.
-- **Command shortcuts are canonical in AppKit menus** (e.g. New Task, Move Up/Down, Mark Completed, Delete). Avoid duplicating those command shortcuts in `TaskListView.keyboardNavigation(...)`.
+- **`MenuCoordinator`** (`ListlessMac/Helpers/AppCommands.swift`) bridges SwiftUI state to AppKit. Enabled state is surfaced via `NSMenuItemValidation.validateMenuItem` on `AppDelegate`.
+- **Command shortcuts are canonical in AppKit menus** (e.g. New Item, Move Up/Down, Mark Completed, Delete). Avoid duplicating those command shortcuts in `TaskListView.keyboardNavigation(...)`.
- **Selector style**: prefer typed `#selector(...)` where available; use `MenuSelectors` constants for string-based selectors that lack typed Swift symbols.
- **Window menu**: keep explicit baseline items in `installMainMenu()` and let `NSApp.windowsMenu` provide system-managed dynamic window list behavior.
+### iOS (iPad)
+- **Menus use UIKit's `buildMenu(with:)`** in `IOSAppDelegate` (`ListlessiOS/ListlessiOSApp.swift`), inserting `UIKeyCommand` items into standard `.file` and `.edit` menus so the iPad keyboard shortcut overlay groups them correctly.
+- **`IOSMenuCoordinator`** (`ListlessiOS/Helpers/AppCommands.swift`) bridges SwiftUI state to UIKit. `IOSMenuActions` protocol declares `@objc` action selectors; `KeyCaptureView` (the first responder in `KeyCommandBridge`) conforms and dispatches to the coordinator.
+- **Conditional availability**: `KeyCaptureView.canPerformAction(_:withSender:)` checks coordinator enabled flags; commands are greyed out in the overlay when conditions aren't met (e.g. Move Up disabled when selected task is first).
+- **Plain (unmodified) key commands** (Up, Down, Space, Return, Delete) are still handled by `KeyCommandBridge`'s `keyCommands` property with `wantsPriorityOverSystemBehavior = true`. Command-modified shortcuts go through `buildMenu` → responder chain → `KeyCaptureView` action methods.
+
## iOS Implementation Notes
- **Platform-specific inits**: `ListlessiOS/Views/TaskRowView.swift` and `ListlessMac/Views/TaskRowView.swift` have diverged — iOS takes `isDragging: Binding<Bool>` while macOS does not. This is fine because each platform has its own `TaskListView.swift` in its platform-specific `Views/` directory, so identical call-site signatures are not required. When adding new parameters, update both `TaskRowView` inits and both `TaskListView` bodies.
- **Swipe gesture**: Pure SwiftUI — `DragGesture(minimumDistance: 10, coordinateSpace: .local)` applied via `.simultaneousGesture()` for scroll coexistence. Direction discrimination happens in `handleDragChanged`: `abs(horizontalTranslation) > verticalTranslation + 10` must hold before any swipe offset is applied, so vertical scrolls pass through without activating the swipe. Haptic feedback uses `@State var hapticTrigger` toggled in `triggerAction` with a `.sensoryFeedback(.impact(weight: .medium), trigger: hapticTrigger)` modifier on the ZStack.
diff --git a/ListlessiOS/Helpers/AppCommands.swift b/ListlessiOS/Helpers/AppCommands.swift
@@ -1,68 +1,48 @@
-import SwiftUI
+import UIKit
-// MARK: - Task Actions
+// MARK: - Menu Coordinator
-struct TaskActions {
+/// Bridges SwiftUI view state to UIKit menu items, mirroring the macOS
+/// `MenuCoordinator` pattern. `TaskListView.updateMenuCoordinator()` keeps
+/// actions and enabled flags current; `KeyCaptureView` dispatches actions
+/// and validates commands via the responder chain.
+@MainActor
+final class IOSMenuCoordinator {
+ static let shared = IOSMenuCoordinator()
+ private init() {}
+
+ // Actions — set by TaskListView on each relevant state change.
var newTask: (() -> Void)?
var deleteTask: (() -> Void)?
var moveUp: (() -> Void)?
var moveDown: (() -> Void)?
var markCompleted: (() -> Void)?
-}
-
-// MARK: - Focused Value Key
-struct TaskActionsKey: FocusedValueKey {
- typealias Value = TaskActions
+ // Enabled state — read by KeyCaptureView in validate(_:).
+ var canDelete = false
+ var canMoveUp = false
+ var canMoveDown = false
+ var canMarkCompleted = false
}
-extension FocusedValues {
- var taskActions: TaskActions? {
- get { self[TaskActionsKey.self] }
- set { self[TaskActionsKey.self] = newValue }
- }
-}
-
-// MARK: - Commands
-
-struct TaskCommands: Commands {
- @FocusedValue(\.taskActions) var actions
+// MARK: - Menu Selectors
- var body: some Commands {
- CommandGroup(after: .newItem) {
- Button("New Item") {
- actions?.newTask?()
- }
- .keyboardShortcut("n")
- .disabled(actions?.newTask == nil)
- }
-
- CommandGroup(replacing: .textEditing) {
- Button("Delete") {
- actions?.deleteTask?()
- }
- .keyboardShortcut(.delete, modifiers: .command)
- .disabled(actions?.deleteTask == nil)
-
- Divider()
-
- Button("Move Up") {
- actions?.moveUp?()
- }
- .keyboardShortcut(.upArrow, modifiers: .command)
- .disabled(actions?.moveUp == nil)
-
- Button("Move Down") {
- actions?.moveDown?()
- }
- .keyboardShortcut(.downArrow, modifiers: .command)
- .disabled(actions?.moveDown == nil)
+/// Selectors for menu item actions routed through the responder chain.
+/// `KeyCaptureView` (first responder) implements these as `@objc` methods.
+enum IOSMenuSelectors {
+ static let newTask = #selector(IOSMenuActions.handleNewTask)
+ static let deleteTask = #selector(IOSMenuActions.handleDeleteTask)
+ static let moveUp = #selector(IOSMenuActions.handleMoveUp)
+ static let moveDown = #selector(IOSMenuActions.handleMoveDown)
+ static let markCompleted = #selector(IOSMenuActions.handleMarkCompleted)
+}
- Button("Mark as Complete") {
- actions?.markCompleted?()
- }
- .keyboardShortcut(.space, modifiers: .command)
- .disabled(actions?.markCompleted == nil)
- }
- }
+/// Protocol declaring the `@objc` action methods so selectors can be
+/// referenced at compile time. `KeyCaptureView` conforms to this.
+@MainActor @objc protocol IOSMenuActions {
+ func handleNewTask()
+ func handleDeleteTask()
+ func handleMoveUp()
+ func handleMoveDown()
+ func handleMarkCompleted()
}
diff --git a/ListlessiOS/Helpers/KeyCommandBridge.swift b/ListlessiOS/Helpers/KeyCommandBridge.swift
@@ -6,6 +6,11 @@ import UIKit
/// programmatic focus (known iPadOS limitation with hardware keyboards).
/// On iPhone, where `@FocusState` works normally, `isActive` stays false
/// and the bridge remains inert.
+///
+/// Also serves as the first responder for menu item actions defined by
+/// `buildMenu(with:)` in the app delegate. Action methods dispatch to
+/// `IOSMenuCoordinator`; `validate(_:)` enables/disables items based on
+/// coordinator state.
struct KeyCommandBridge: UIViewRepresentable {
let isActive: Bool
let onUp: () -> Void
@@ -41,7 +46,7 @@ struct KeyCommandBridge: UIViewRepresentable {
}
}
- final class KeyCaptureView: UIView {
+ final class KeyCaptureView: UIView, IOSMenuActions {
var isActive = false
var onUp: (() -> Void)?
var onDown: (() -> Void)?
@@ -51,6 +56,8 @@ struct KeyCommandBridge: UIViewRepresentable {
override var canBecomeFirstResponder: Bool { true }
+ // MARK: - Plain key commands (no modifiers)
+
override var keyCommands: [UIKeyCommand]? {
guard isActive else { return nil }
return [
@@ -86,5 +93,46 @@ struct KeyCommandBridge: UIViewRepresentable {
break
}
}
+
+ // MARK: - Menu item actions (from buildMenu via responder chain)
+
+ @objc func handleNewTask() {
+ IOSMenuCoordinator.shared.newTask?()
+ }
+
+ @objc func handleDeleteTask() {
+ IOSMenuCoordinator.shared.deleteTask?()
+ }
+
+ @objc func handleMoveUp() {
+ IOSMenuCoordinator.shared.moveUp?()
+ }
+
+ @objc func handleMoveDown() {
+ IOSMenuCoordinator.shared.moveDown?()
+ }
+
+ @objc func handleMarkCompleted() {
+ IOSMenuCoordinator.shared.markCompleted?()
+ }
+
+ // MARK: - Menu validation
+
+ override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
+ switch action {
+ case IOSMenuSelectors.newTask:
+ return isActive
+ case IOSMenuSelectors.deleteTask:
+ return isActive && IOSMenuCoordinator.shared.canDelete
+ case IOSMenuSelectors.moveUp:
+ return isActive && IOSMenuCoordinator.shared.canMoveUp
+ case IOSMenuSelectors.moveDown:
+ return isActive && IOSMenuCoordinator.shared.canMoveDown
+ case IOSMenuSelectors.markCompleted:
+ return isActive && IOSMenuCoordinator.shared.canMarkCompleted
+ default:
+ return super.canPerformAction(action, withSender: sender)
+ }
+ }
}
}
diff --git a/ListlessiOS/ListlessiOSApp.swift b/ListlessiOS/ListlessiOSApp.swift
@@ -1,7 +1,66 @@
import SwiftUI
+// MARK: - App Delegate
+
+class IOSAppDelegate: UIResponder, UIApplicationDelegate {
+ override func buildMenu(with builder: UIMenuBuilder) {
+ guard builder.system == .main else {
+ super.buildMenu(with: builder)
+ return
+ }
+
+ // File menu — New Item (⌘N)
+ let newItem = UIKeyCommand(
+ title: "New Item",
+ action: IOSMenuSelectors.newTask,
+ input: "n",
+ modifierFlags: .command
+ )
+ builder.insertChild(
+ UIMenu(title: "", options: .displayInline, children: [newItem]),
+ atStartOfMenu: .file
+ )
+
+ // Edit menu — Move Up (⌘↑), Move Down (⌘↓), Delete (⌘⌫),
+ // Mark as Complete (⌘Space)
+ let moveUp = UIKeyCommand(
+ title: "Move Up",
+ action: IOSMenuSelectors.moveUp,
+ input: UIKeyCommand.inputUpArrow,
+ modifierFlags: .command
+ )
+ let moveDown = UIKeyCommand(
+ title: "Move Down",
+ action: IOSMenuSelectors.moveDown,
+ input: UIKeyCommand.inputDownArrow,
+ modifierFlags: .command
+ )
+ let delete = UIKeyCommand(
+ title: "Delete",
+ action: IOSMenuSelectors.deleteTask,
+ input: "\u{8}",
+ modifierFlags: .command
+ )
+ let markComplete = UIKeyCommand(
+ title: "Mark as Complete",
+ action: IOSMenuSelectors.markCompleted,
+ input: " ",
+ modifierFlags: .command
+ )
+ builder.insertChild(
+ UIMenu(title: "", options: .displayInline, children: [
+ moveUp, moveDown, delete, markComplete,
+ ]),
+ atEndOfMenu: .edit
+ )
+ }
+}
+
+// MARK: - App
+
@main
struct ListlessiOSApp: App {
+ @UIApplicationDelegateAdaptor(IOSAppDelegate.self) var appDelegate
@AppStorage("appearanceMode") private var appearanceMode = 0
private let persistenceController: PersistenceController
private let keyValueSyncBridge = KeyValueSyncBridge(keys: ["headingText"])
@@ -42,8 +101,5 @@ struct ListlessiOSApp: App {
.frame(height: 0)
}
}
- .commands {
- TaskCommands()
- }
}
}
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -118,23 +118,53 @@ struct TaskListView: View, TaskListViewProtocol {
)
}
- private var currentTaskActions: TaskActions {
- let selectedIndex = selectedTaskID.flatMap { id in
- activeTasks.firstIndex(where: { $0.id == id })
- }
- return TaskActions(
- newTask: { createNewTask() },
- deleteTask: selectedTaskID != nil
- ? { _ = deleteSelectedTask() } : nil,
- moveUp: selectedIndex.map({ $0 > 0 }) == true
- ? { moveSelectedTaskUp() } : nil,
- moveDown: selectedIndex.map({ $0 < activeTasks.count - 1 }) == true
- ? { moveSelectedTaskDown() } : nil,
- markCompleted: selectedTaskID != nil
- ? { markSelectedTaskCompleted() } : nil
+ private var selectedIndex: Int? {
+ guard let currentID = selectedTaskID else { return nil }
+ return activeTasks.firstIndex(where: { $0.id == currentID })
+ }
+
+ private var canMoveSelectionUp: Bool {
+ guard focusedField == .scrollView else { return false }
+ guard let index = selectedIndex else { return false }
+ return index > 0
+ }
+
+ private var canMoveSelectionDown: Bool {
+ guard focusedField == .scrollView else { return false }
+ guard let index = selectedIndex else { return false }
+ return index < activeTasks.count - 1
+ }
+
+ private struct MenuState: Equatable {
+ let selectedTaskID: UUID?
+ let isScrollViewFocused: Bool
+ let activeTaskCount: Int
+ let selectedIndex: Int?
+ }
+
+ private var menuCoordinatorTrigger: MenuState {
+ MenuState(
+ selectedTaskID: selectedTaskID,
+ isScrollViewFocused: focusedField == .scrollView,
+ activeTaskCount: activeTasks.count,
+ selectedIndex: selectedIndex
)
}
+ func updateMenuCoordinator() {
+ let coord = IOSMenuCoordinator.shared
+ coord.newTask = { createNewTask() }
+ coord.deleteTask = { _ = deleteSelectedTask() }
+ coord.moveUp = { moveSelectedTaskUp() }
+ coord.moveDown = { moveSelectedTaskDown() }
+ coord.markCompleted = { markSelectedTaskCompleted() }
+ let inNavMode = focusedField == .scrollView
+ coord.canDelete = selectedTaskID != nil && inNavMode
+ coord.canMoveUp = canMoveSelectionUp
+ coord.canMoveDown = canMoveSelectionDown
+ coord.canMarkCompleted = selectedTaskID != nil && inNavMode
+ }
+
var vStackSpacing: CGFloat { 12 }
var pullCreateThreshold: CGFloat { 70 }
var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty }
@@ -178,7 +208,9 @@ struct TaskListView: View, TaskListViewProtocol {
}
.onAppear {
fState.focusedField = .scrollView
+ updateMenuCoordinator()
}
+ .onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
.onChange(of: undoManager, initial: true) { _, newValue in
managedObjectContext.undoManager = newValue
}
@@ -204,7 +236,6 @@ struct TaskListView: View, TaskListViewProtocol {
.padding(.trailing, 12)
}
}
- .focusedSceneValue(\.taskActions, currentTaskActions)
.sheet(isPresented: isShowingSyncDiagnosticsStateBinding) {
NavigationStack {
SyncDiagnosticsView(syncMonitor: syncMonitor)