listless

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

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 }