KeyCommandBridge.swift (6351B)
1 import SwiftUI 2 import UIKit 3 4 /// UIViewRepresentable that captures keyboard input via UIKeyCommand when 5 /// SwiftUI's `.focusable()` / `@FocusState` system fails to accept 6 /// programmatic focus (known iPadOS limitation with hardware keyboards). 7 /// On iPhone, where `@FocusState` works normally, `isActive` stays false 8 /// and the bridge remains inert. 9 /// 10 /// Also serves as the first responder for menu item actions defined by 11 /// `buildMenu(with:)` in the app delegate. Action methods dispatch to 12 /// `IOSMenuCoordinator`; `validate(_:)` enables/disables items based on 13 /// coordinator state. 14 struct KeyCommandBridge: UIViewRepresentable { 15 let isActive: Bool 16 let onUp: () -> Void 17 let onDown: () -> Void 18 let onSpace: () -> Void 19 let onReturn: () -> Void 20 let onDelete: () -> Void 21 let onHome: () -> Void 22 let onEnd: () -> Void 23 let onPageUp: () -> Void 24 let onPageDown: () -> Void 25 26 func makeUIView(context: Context) -> KeyCaptureView { 27 let view = KeyCaptureView() 28 view.onUp = onUp 29 view.onDown = onDown 30 view.onSpace = onSpace 31 view.onReturn = onReturn 32 view.onDelete = onDelete 33 view.onHome = onHome 34 view.onEnd = onEnd 35 view.onPageUp = onPageUp 36 view.onPageDown = onPageDown 37 view.isActive = isActive 38 return view 39 } 40 41 func updateUIView(_ view: KeyCaptureView, context: Context) { 42 view.onUp = onUp 43 view.onDown = onDown 44 view.onSpace = onSpace 45 view.onReturn = onReturn 46 view.onDelete = onDelete 47 view.onHome = onHome 48 view.onEnd = onEnd 49 view.onPageUp = onPageUp 50 view.onPageDown = onPageDown 51 view.isActive = isActive 52 53 if isActive && !view.isFirstResponder { 54 DispatchQueue.main.async { [weak view] in 55 guard let view, view.isActive else { return } 56 view.becomeFirstResponder() 57 } 58 } 59 } 60 61 final class KeyCaptureView: UIView, IOSMenuActions { 62 var isActive = false 63 var onUp: (() -> Void)? 64 var onDown: (() -> Void)? 65 var onSpace: (() -> Void)? 66 var onReturn: (() -> Void)? 67 var onDelete: (() -> Void)? 68 var onHome: (() -> Void)? 69 var onEnd: (() -> Void)? 70 var onPageUp: (() -> Void)? 71 var onPageDown: (() -> Void)? 72 73 override var canBecomeFirstResponder: Bool { true } 74 75 // MARK: - Plain key commands (no modifiers) 76 77 override var keyCommands: [UIKeyCommand]? { 78 guard isActive else { return nil } 79 return [ 80 UIKeyCommand.inputUpArrow, 81 UIKeyCommand.inputDownArrow, 82 " ", 83 "\r", 84 "\u{8}", 85 UIKeyCommand.inputHome, 86 UIKeyCommand.inputEnd, 87 UIKeyCommand.inputPageUp, 88 UIKeyCommand.inputPageDown, 89 ].map { input in 90 let cmd = UIKeyCommand( 91 input: input, 92 modifierFlags: [], 93 action: #selector(handleKeyCommand(_:)) 94 ) 95 cmd.wantsPriorityOverSystemBehavior = true 96 return cmd 97 } 98 } 99 100 @objc private func handleKeyCommand(_ sender: UIKeyCommand) { 101 switch sender.input { 102 case UIKeyCommand.inputUpArrow: 103 onUp?() 104 case UIKeyCommand.inputDownArrow: 105 onDown?() 106 case " ": 107 onSpace?() 108 case "\r": 109 onReturn?() 110 case "\u{8}": 111 onDelete?() 112 case UIKeyCommand.inputHome: 113 onHome?() 114 case UIKeyCommand.inputEnd: 115 onEnd?() 116 case UIKeyCommand.inputPageUp: 117 onPageUp?() 118 case UIKeyCommand.inputPageDown: 119 onPageDown?() 120 default: 121 break 122 } 123 } 124 125 // MARK: - Menu item actions (from buildMenu via responder chain) 126 127 @objc func handleNewItem() { 128 IOSMenuCoordinator.shared.newItem?() 129 } 130 131 @objc func handleDeleteItem() { 132 IOSMenuCoordinator.shared.deleteItem?() 133 } 134 135 @objc func handleMoveUp() { 136 IOSMenuCoordinator.shared.moveUp?() 137 } 138 139 @objc func handleMoveDown() { 140 IOSMenuCoordinator.shared.moveDown?() 141 } 142 143 @objc func handleMarkCompleted() { 144 IOSMenuCoordinator.shared.markCompleted?() 145 } 146 147 @objc func handleNavigatePageUp() { 148 IOSMenuCoordinator.shared.navigatePageUp?() 149 } 150 151 @objc func handleNavigatePageDown() { 152 IOSMenuCoordinator.shared.navigatePageDown?() 153 } 154 155 @objc func handleNavigateToFirst() { 156 IOSMenuCoordinator.shared.navigateToFirst?() 157 } 158 159 @objc func handleNavigateToLast() { 160 IOSMenuCoordinator.shared.navigateToLast?() 161 } 162 163 // MARK: - Menu validation 164 165 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { 166 switch action { 167 case IOSMenuSelectors.newItem: 168 return isActive 169 case IOSMenuSelectors.deleteItem: 170 return isActive && IOSMenuCoordinator.shared.canDelete 171 case IOSMenuSelectors.moveUp: 172 return isActive && IOSMenuCoordinator.shared.canMoveUp 173 case IOSMenuSelectors.moveDown: 174 return isActive && IOSMenuCoordinator.shared.canMoveDown 175 case IOSMenuSelectors.markCompleted: 176 return isActive && IOSMenuCoordinator.shared.canMarkCompleted 177 case IOSMenuSelectors.navigatePageUp, 178 IOSMenuSelectors.navigatePageDown, 179 IOSMenuSelectors.navigateToFirst, 180 IOSMenuSelectors.navigateToLast: 181 return isActive 182 default: 183 return super.canPerformAction(action, withSender: sender) 184 } 185 } 186 187 override func validate(_ command: UICommand) { 188 super.validate(command) 189 if command.action == IOSMenuSelectors.markCompleted { 190 command.title = IOSMenuCoordinator.shared.markCompletedTitle 191 } 192 } 193 } 194 }