LoginSignup
5
5

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

Last updated at Posted at 2021-01-06

はじめに

今回の記事は、例えば電卓アプリを作成する場合、表示ラベル、数字キーと演算(+/-/×/÷/=)キーボタンを配置して、ボタンタップやマウスクリックで操作するシーンは普通であるが、ここに物理キーボードのキー押下でも、操作したい場合の話である。(テキスト入力フィールドを持つアプリであれば、特に何も困らないので。)
また、同一ソースコードで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によるMacOSアプリの場合は、NSViewクラスのkeyDown(with:)keyUp(with:)メソッドを実装すれば良いが、このメソッドはUIViewクラスには存在しない。
UIKitだとUIViewクラスのpressesBegan(:with:)pressesEnded(:with:)メソッドを実装することになるが、UIKitネイティブのiOS/iPadOSなら問題なく動作するが、Mac(Catalyst)だと一部のキーは動作するがほとんどのキーはコンソールに「Warning: insertText reached」が出力され上記メソッドは呼ばれない。こちらの記事 1 と似たような現象だが解決されてない。

他の方法としては、iOS14からサポートされたGame ControllerによるGCKeyboardクラスを使用する方法がある。 使い方の概要は以下の通り。

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での回避策 2 3 4NSViewを使わないMac(Catalyst)では適用できない。

2. Beep音回避

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

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型で通知されるが、この型はキーボードのキートップの刻印とは必ずしも一致しないので、注意が必要である。

いくつか例を示す。

・ イコール記号(=)

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型であれば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) | |

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

・ キーボード自体の識別

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

keyboard.png

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

4. Macキーボードの例

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

JISキーボード
JISキーボード

USキーボード
USキーボード

最後に

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

おまけ

UIKeyModifierFlagsGCKeyCodeを可視化(文字化)するエクステンションを載せておく。

ここに表示
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
    }
}
  1. Conflict with UIKeyInput protocol

  2. Removing the error sound on keydown macOS

  3. osX keyDownメソッド実行時にBeep音が鳴るようになった話

  4. OSX ショートカットキー入力の際Beep音を鳴らさない方法

  5. UIKit macOS Catalyst beep sound with GCKeyboard

5
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5