はじめに
今回の記事は、例えば電卓アプリを作成する場合、表示ラベル、数字キーと演算(+/-/×/÷/=)キーボタンを配置して、ボタンタップやマウスクリックで操作するシーンは普通であるが、ここに物理キーボードのキー押下でも、操作したい場合の話である。(テキスト入力フィールドを持つアプリであれば、特に何も困らないので。)
また、同一ソースコードで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 4 はNSView
を使わない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) |
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
でキー数等は判別できるが、明確にキーボードの種類を識別できる情報は無い。そもそもJISキーボードでハイフンの右隣のキー**^**(ハット)を押すと、なんとequalSign
を返してくる。これはUSキーボード配列のコードである。
電卓アプリ程度であれば使用するキーが限定的なため上記の点だけを注意すれば実現可能であるが、viの様なスクリーンエディタを開発するとなると、キーボードイベントのハンドリングに相当な労力が必要と思われる。
4. Macキーボードの例
Apple純正キーボードのキー配列は以下の通り。
最後に
『Mac(Catalyst)でキーボードイベントが拾えない。キーを叩くとBeep音が出る。』この調査にほぼ一日を要したので、ここにまとめておいた。未調査/未検証事項は後日必要時に追記することとする。 (2021.1.8追記)
そもそもGame Controller
はゲームアプリのためのフレームワークであるから、これでviの様なスクリーンエディタを開発しようとすること自体に無理があるのだろう。
以上
おまけ
UIKeyModifierFlags
とGCKeyCode
を可視化(文字化)するエクステンションを載せておく。
ここに表示
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
}
}