[Swift] Mac Catalystでのキーボードイベントについて

# はじめに

今回の記事は、例えば**電卓アプリ**を作成する場合、表示**ラベル**、数字キーと演算(+/-/×/÷/=)キー**ボタン**を配置して、ボタン**タップ**やマウス**クリック**で操作するシーンは普通であるが、ここに物理キーボードの**キー**押下でも、操作したい場合の話である。（テキスト入力フィールドを持つアプリであれば、特に何も困らないので。）
また、同一ソースコードでiOS(含むiPadOS)とMacOS(Mac Catalyst)の全部に対応したい場合の話である。

記事執筆時点の環境は以下の通り。

開発環境version
- MacOS ： 11.1 (20C69)
- Xcode ： 12.3 (12C33)
- Swift ： 5.3.2 (swiftlang-1200.0.45 clang-1200.0.32.28)

ターゲット環境version
- iOS/iPadOS ： 14.0
- MacOS ： 11.0


# 1. キーボードイベントの拾い方

[APPKit](https://developer.apple.com/documentation/appkit)によるMacOSアプリの場合は、[`NSView`](https://developer.apple.com/documentation/appkit/nsview)クラスの[`keyDown(with:)`](https://developer.apple.com/documentation/appkit/nsresponder/1525805-keydown)と[`keyUp(with:)`](https://developer.apple.com/documentation/appkit/nsresponder/1527436-keyup)メソッドを実装すれば良いが、このメソッドは[`UIView`](https://developer.apple.com/documentation/uikit/uiview)クラスには存在しない。
[UIKit](https://developer.apple.com/documentation/uikit)だと`UIView`クラスの[`pressesBegan(:with:)`](https://developer.apple.com/documentation/uikit/uiresponder/1621134-pressesbegan)と[`pressesEnded(:with:)`](https://developer.apple.com/documentation/uikit/uiresponder/1621128-pressesended)メソッドを実装することになるが、UIKitネイティブのiOS/iPadOSなら問題なく動作するが、Mac(Catalyst)だと一部のキーは動作するがほとんどのキーはコンソールに「Warning: insertText reached」が出力され上記メソッドは呼ばれない。こちらの記事 [^0] と似たような現象だが解決されてない。

[^0]: [Conflict with UIKeyInput protocol](https://developer.apple.com/forums/thread/655395)

他の方法としては、iOS14からサポートされた[`Game Controller`](https://developer.apple.com/documentation/gamecontroller)による[`GCKeyboard`](https://developer.apple.com/documentation/gamecontroller/gckeyboard)クラスを使用する方法がある。 使い方の概要は以下の通り。

```swift
if let keyboard = GCKeyboard.coalesced?.keyboardInput {
    // bind to any key-down/up
    keyboard.keyChangedHandler = {
        (keyboard, key, keyCode, pressed) in
        // compare button to GCKeyCode
        ・・・
    }

    // bind to specific key-down/up
    keyboard.button(forKeyCode: .spacebar)?.valueChangedHandler = {
        (key, value, pressed) in
        // SpaceBar was pressed or released
        ・・・
    }
}
```

 試した限り、`GCKeyboard`クラスを使用すれば、iOS/iPadOSとMac(Catalyst)の全部で動作した。
ただし、Macについては、一部のキー押下でBeep音が出る現象に遭遇した。これは、APPKitによるMacOSアプリで`NSView`クラスの`keyDown(with:)`と`keyUp(with:)`メソッドを実装した時に発生した現象と同じであるが、MacAppでの回避策 [^1] [^2] [^3] は`NSView`を使わないMac(Catalyst)では適用できない。

[^1]: [Removing the error sound on keydown macOS](https://developer.apple.com/forums/thread/651252)
[^2]: [osX keyDownメソッド実行時にBeep音が鳴るようになった話](https://qiita.com/Kyome/items/ba5d93f5cc445e138d6d)
[^3]: [OSX ショートカットキー入力の際Beep音を鳴らさない方法](https://qiita.com/Kyome/items/241f38f7bbbcf77c1ad0)


# ２. Beep音回避

Mac(Catalyst)でGCKeyboard使った時のBeep音の回避策はズバリ、ここ [^4] に載っていた。
`pressesBegan` `pressesEnded`をダミー実装してアプリケーションレベルにメッセージを伝達させないことである。心配なので`pressesCancelled`もダミー実装した。

[^4]: [UIKit macOS Catalyst beep sound with GCKeyboard](https://developer.apple.com/forums/thread/651252)


```swift
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { }
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) { }
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) { }
```

# 3. GCKeyCodeのハンドリング

`GCKeyboard`クラスによるキーボードイベントのハンドリングは、非常にローレベルなハンドリングとなる。どのキーが押されたのか/離されたのかが[GCKeyCode型](https://developer.apple.com/documentation/gamecontroller/gckeycode)で通知されるが、この型はキーボードのキートップの刻印とは必ずしも一致しないので、注意が必要である。

**いくつか例を示す。**

### ・ イコール記号(=)

JISキーボードだと shiftキーと-(ハイフン)キー であるため、`leftShift`(`rightShift`)と`hyphen`の2回のイベントに別れて通知される。
USキーボードだと =(イコール)キー であるため、`equalSign`の1回のイベントで通知される。

つまり、同じ文字でもJIS/USキーボードで通知されるコードが違うことがある。左右の2つのシフトキーは区別され独立して通知される。

### ・ 数字のゼロ(0)

普通はキーボード上部に数字キーは並ぶが、フルキーボードだと右側にテンキーとしても数字キーが配置され、同じ0(数字のゼロ)でもコードが違う。
キーボード上部の数字0は`zero`。テンキーの数字0は`keypad0`。 余談であるが、個人的には`zero`ではなく、アルファベットと同じように`key0`として欲しかった。

つまり、同じキートップ文字でも、キーの場所が違うと通知されるコードが違う。

### ・ aとA（小文字/大文字）の扱い

CapsLockされていない限り、英文字は小文字で入力されるのが普通である。大文字で入力する場合はシフトキーと同時に押す。一方、CapsLockされている場合は、大文字で入力され、シフトキーと同時に押すと小文字になる。
シフトキー**＋**Aの場合に、大文字と解釈するのか小文字と解釈するのか、CapsLockの状態に依存するが、これを知ることができるのか不明である。英小文字と大文字を区別したいアプリの場合はこのハンドリングが難しいだろう。
（アプリ起動後のCapsLockキー押下はイベント通されるが、アプリ起動前にすでにCapsLock状態の場合だと、逆の意味となる）

[UIKey型](https://developer.apple.com/documentation/uikit/uikey)であれば[`modifierFlags`](https://developer.apple.com/documentation/uikit/uikey/3526133-modifierflags)によってCapsLock状態を知ることができるので、`pressesBegan`イベントを併用すれば判断が可能か？ ~~(試してないので併用できるか不明)~~ （2021.1.7追記）
`pressesBegan`イベントを併用できることを検証したが、それでも工夫が必要である。

### ・ `pressesBegan`イベントの併用における注意点

iOSとMac(Catalyst)で挙動が異なる。OSのバグの可能性もあるので検証時のバージョンを明示しておく。
- MacOS 11.1(20C69)
- iOS 14.2 (18B92)


#### メソッドの呼び出し(順)

- Mac Catalyst

|#| メソッド | Aキーのみ | シフトキーのみ | シフトキー**＋**Aキー | 備考 |
|:-:|:-:|:-:|:-:|:-:|:-:|
|1| keyChangedHandler pressed=true  | 1 | 1 | 1 (s), 3 (a) | (s)シフトキー |
|2| keyChangedHandler pressed=false | 2 | 3 | 4 (a), 5 (s) | (a)Aキー |
|3| pressesBegan  | - | 2 | 2 (s) | |
|4| pressesEnded  | - | 4 | 6 (s) | |

Aキーにおいて`pressesBegan`/`pressesEnded`が呼ばれないのは、1項で説明した通り。

- iOS

|#| メソッド | Aキーのみ | シフトキーのみ | シフトキー**＋**Aキー | 備考 |
|:-:|:-:|:-:|:-:|:-:|:-:|
|1| keyChangedHandler pressed=true  | 2 | 2 | 2 (s), 4 (a) | (s)シフトキー |
|2| keyChangedHandler pressed=false | 4 | 4 | 6 (a), 8 (s) | (a)Aキー |
|3| pressesBegan  | 1 | 1 | 1 (s), 3(a) | |
|4| pressesEnded  | 3 | 3 | 5 (a), 7(s) | |

`keyChangedHandler`と`pressesBegan(/Ended)`が呼ばれる順がMac(Catalys)と逆になる。

#### CapsLockキーの扱い

| # | キーの押下順 | Mac | iOS | CapsLock状態を示すLED |
|:-:|:-:|:-:|:-:|:-:|
| 0 | - | - | - | 　消灯 |
| 1  | CapsLockキーを押す  | pressed=true, PressesBeganが呼ばれる  | PressesBegan, pressed=trueが呼ばれる  | 点灯 |
| 2  | CapsLockキーを離す  | 何も呼ばれない  | PressesEended, pressed=falseが呼ばれる  | 点灯 |
| 3  | Aキーを押す  | pressed=trueが呼ばれる  | PressesBegan, pressed=trueが呼ばれる  | 点灯 |
| 4  | Aキーを離す  | pressed=falseが呼ばれる  | PressesEended, pressed=falseが呼ばれる  | 点灯 |
| 5  | CapsLockキーを押す  | pressed=false, PressesEendedが呼ばれる  | PressesBegan, pressed=trueが呼ばれる  | 　消灯 |
| 6  | CapsLockキーを離す  | 何も呼ばれない  | PressesEended, pressed=falseが呼ばれる  | 　消灯 |

`PressesBegan`に渡される`UIPress.key.modifierFlags`の`alphaShift`がCapsLock状態を示す為、アプリ起動後のCapsLock状態はMac/iOSとも、正しく認識可能である。
しかし、アプリ起動前にCapsLock状態にしておいた場合は、Mac/iOSとも、正しくCapsLock状態を認識できない。理由は、Macの場合、Aキーで`PressesBegan`が呼ばれない為。iOSの場合、`UIPress.key.modifierFlags`の`alphaShift`が真逆にセットされている為である。iOSはバグの可能性がある。


### ・ キーボード自体の識別

JISとUSキーボードではキー配列に大きな違いがあるが、JISキーボードのキーが押されたのか、USキーボードのキーが押されたのか、~~これを区別する方法は未調査である。~~ これを区別できない。（2021.1.8追記）
[`GCPhysicalInputProfile`](https://developer.apple.com/documentation/gamecontroller/gcphysicalinputprofile)でキー数等は判別できるが、明確にキーボードの種類を識別できる情報は無い。そもそもJISキーボードでハイフンの右隣のキー**^**(ハット)を押すと、なんと`equalSign`を返してくる。これはUSキーボード配列のコードである。

![keyboard.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/ed3a674c-6f3b-8088-620f-c0a660448832.png)


電卓アプリ程度であれば使用するキーが限定的なため上記の点だけを注意すれば実現可能であるが、viの様なスクリーンエディタを開発するとなると、キーボードイベントのハンドリングに相当な労力が必要と思われる。


# 4. Macキーボードの例

Apple純正キーボードのキー配列は以下の通り。

JISキーボード
![JISキーボード](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/bed3ee8b-0e8c-2fcf-2c41-26a152dc23b3.png)

USキーボード
![USキーボード](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283293/01799039-0216-ee48-6148-df7d7ccd0ace.png)

# 最後に

『Mac(Catalyst)でキーボードイベントが拾えない。キーを叩くとBeep音が出る。』この調査にほぼ一日を要したので、ここにまとめておいた。~~未調査/未検証事項は後日必要時に追記することとする。~~ （2021.1.8追記）
そもそも`Game Controller`はゲームアプリのためのフレームワークであるから、これでviの様なスクリーンエディタを開発しようとすること自体に無理があるのだろう。
以上

# おまけ

[`UIKeyModifierFlags`](https://developer.apple.com/documentation/uikit/uikeymodifierflags)と[`GCKeyCode`]((https://developer.apple.com/documentation/gamecontroller/gckeycode))を可視化（文字化）するエクステンションを載せておく。
<details><summary>ここに表示</summary><div>

```swift
extension UIKeyModifierFlags {
    var toString: String {
        var result = "["
        let keys: [UIKeyModifierFlags] = [.alphaShift, .shift, .control, .alternate, .command, .numericPad]
        let strs = ["alphaShift", "shift", "control", "alternate", "command", "numericPad"]
        for n in keys.indices {
            if self.contains(keys[n]) {
                if result.count == 1 {
                    result += strs[n]
                } else {
                    result += " ," + strs[n]
                }
            }
        }
        result += "]"
        return result
    }
}

extension GCKeyCode {
    var toString: String {
        let str: String
        switch self {
        case .F1: str = "F1"
        case .F10: str = "F10"
        case .F11: str = "F11"
        case .F12: str = "F12"
        case .F2: str = "F2"
        case .F3: str = "F3"
        case .F4: str = "F4"
        case .F5: str = "F5"
        case .F6: str = "F6"
        case .F7: str = "F7"
        case .F8: str = "F8"
        case .F9: str = "F9"
        case .LANG1: str = "LANG1"
        case .LANG2: str = "LANG2"
        case .LANG3: str = "LANG3"
        case .LANG4: str = "LANG4"
        case .LANG5: str = "LANG5"
        case .LANG6: str = "LANG6"
        case .LANG7: str = "LANG7"
        case .LANG8: str = "LANG8"
        case .LANG9: str = "LANG9"
        case .application: str = "application"
        case .backslash: str = "backslash"
        case .capsLock: str = "capsLock"
        case .closeBracket: str = "closeBracket"
        case .comma: str = "comma"
        case .deleteForward: str = "deleteForward"
        case .deleteOrBackspace: str = "deleteOrBackspace"
        case .downArrow: str = "downArrow"
        case .eight: str = "eight"
        case .end: str = "end"
        case .equalSign: str = "equalSign"
        case .escape: str = "escape"
        case .five: str = "five"
        case .four: str = "four"
        case .graveAccentAndTilde: str = "graveAccentAndTilde"
        case .home: str = "home"
        case .hyphen: str = "hyphen"
        case .insert: str = "insert"
        case .international1: str = "international1"
        case .international2: str = "international2"
        case .international3: str = "international3"
        case .international4: str = "international4"
        case .international5: str = "international5"
        case .international6: str = "international6"
        case .international7: str = "international7"
        case .international8: str = "international8"
        case .international9: str = "international9"
        case .keyA: str = "keyA"
        case .keyB: str = "keyB"
        case .keyC: str = "keyC"
        case .keyD: str = "keyD"
        case .keyE: str = "keyE"
        case .keyF: str = "keyF"
        case .keyG: str = "keyG"
        case .keyH: str = "keyH"
        case .keyI: str = "keyI"
        case .keyJ: str = "keyJ"
        case .keyK: str = "keyK"
        case .keyL: str = "keyL"
        case .keyM: str = "keyM"
        case .keyN: str = "keyN"
        case .keyO: str = "keyO"
        case .keyP: str = "keyP"
        case .keyQ: str = "keyQ"
        case .keyR: str = "keyR"
        case .keyS: str = "keyS"
        case .keyT: str = "keyT"
        case .keyU: str = "keyU"
        case .keyV: str = "keyV"
        case .keyW: str = "keyW"
        case .keyX: str = "keyX"
        case .keyY: str = "keyY"
        case .keyZ: str = "keyZ"
        case .keypad0: str = "keypad0"
        case .keypad1: str = "keypad1"
        case .keypad2: str = "keypad2"
        case .keypad3: str = "keypad3"
        case .keypad4: str = "keypad4"
        case .keypad5: str = "keypad5"
        case .keypad6: str = "keypad6"
        case .keypad7: str = "keypad7"
        case .keypad8: str = "keypad8"
        case .keypad9: str = "keypad9"
        case .keypadAsterisk: str = "keypadAsterisk"
        case .keypadEnter: str = "keypadEnter"
        case .keypadEqualSign: str = "keypadEqualSign"
        case .keypadHyphen: str = "keypadHyphen"
        case .keypadNumLock: str = "keypadNumLock"
        case .keypadPeriod: str = "keypadPeriod"
        case .keypadPlus: str = "keypadPlus"
        case .keypadSlash: str = "keypadSlash"
        case .leftAlt: str = "leftAlt"
        case .leftArrow: str = "leftArrow"
        case .leftControl: str = "leftControl"
        case .leftGUI: str = "leftGUI"
        case .leftShift: str = "leftShift"
        case .nine: str = "nine"
        case .nonUSBackslash: str = "nonUSBackslash"
        case .nonUSPound: str = "nonUSPound"
        case .one: str = "one"
        case .openBracket: str = "openBracket"
        case .pageDown: str = "pageDown"
        case .pageUp: str = "pageUp"
        case .pause: str = "pause"
        case .period: str = "period"
        case .power: str = "power"
        case .printScreen: str = "printScreen"
        case .quote: str = "quote"
        case .returnOrEnter: str = "returnOrEnter"
        case .rightAlt: str = "rightAlt"
        case .rightArrow: str = "rightArrow"
        case .rightControl: str = "rightControl"
        case .rightGUI: str = "rightGUI"
        case .rightShift: str = "rightShift"
        case .scrollLock: str = "scrollLock"
        case .semicolon: str = "semicolon"
        case .seven: str = "seven"
        case .six: str = "six"
        case .slash: str = "slash"
        case .spacebar: str = "spacebar"
        case .tab: str = "tab"
        case .three: str = "three"
        case .two: str = "two"
        case .upArrow: str = "upArrow"
        case .zero: str = "zero"
        default: str = "???"
        }
        return str
    }
}
```
</div></details>