- 投稿日:2021-01-06T23:43:38+09:00
Mac Catalystでのキーボードイベントについて 【Xcode & Swift】
はじめに
今回の記事は、例えば電卓アプリを作成する場合、表示ラベル、数字キーと演算(+/-/×/÷/=)キーボタンを配置して、ボタンタップやマウスクリックで操作するシーンは普通であるが、ここに物理キーボードのキー押下でも、操作したい場合の話である。(テキスト入力フィールドを持つアプリであれば、特に何も困らないので。)
また、同一ソースコードで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.01. キーボードイベントの拾い方
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が呼ばれる PressesBegan, pressed=trueが呼ばれる 点灯 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 } }
- 投稿日:2021-01-06T20:48:11+09:00
[Swift] プロパティオブザーバ willSetとdidSet
プロパティオブザーバとは
格納型プロパティの値が更新される時、それをきっかけにして処理を書く事が出来ます。
例えば、プロパティオブザーバを使用する事で
var price = 100 の値を更新した時に、更新の回数をカウントする処理を書く事が出来たりする訳です。
このプロパティオブザーバには、値が変更される直前に呼び出されるwillSet
と変更後に呼び出されるdidSet
があります。
これらはどちらか一方だけ書くこともできます。
書き方は以下の通りです。var プロパティ名: 型 = 初期値 { willSet (仮引数) { 変更直前に呼ばれる処理 } didSet (仮引数) { 変更直後に呼ばれる処理 } }それぞれの仮引数
willSet
とdidSet
にはそれぞれ仮引数を指定する事が出来ます。
willSet
では、プロパティに格納される直前の新しい値を仮引数で参照する事が出来ます。
仮引数を省略する場合は、newValue
という名前で参照できます。
didSet
では、プロパティに今まで格納されていた古い値を仮引数で参照できます。
省略する場合は、oldValue
で参照できます。willSet使ってみた
textFieldに購入金額を入力して、ボタンを押すとその回数と前回の金額との差を表示するアプリを作成してみました。
class ViewController: UIViewController { @IBOutlet var label: UILabel! @IBOutlet var priceTextField: UITextField! @IBOutlet var countLabel: UILabel! @IBOutlet var hikakuLabel: UILabel! var count = 0 var priceDifference = "" let minPrice = 1.0 var buyingPrice: Double = 0 { willSet { count += 1 guard newValue > buyingPrice else { priceDifference = "前回より\(buyingPrice - newValue)円低い購入価格です" return } priceDifference = "前回より\(newValue - buyingPrice)高い購入価格です" } } @IBAction func countUpButton(_ sender: Any) { buyingPrice = Double(priceTextField.text!) ?? 0 countLabel.text = "購入回数は\(count)回です" hikakuLabel.text = priceDifference } }実行結果
willSet
内で購入回数を取得し、価格差を計算して表示する事が出来ました。didSet使ってみた
didSet
を使用して数字の最低値を設定し、それ以下にならないようにコードを書いてみます。class ViewController: UIViewController { @IBOutlet var numberTextFIeld: UITextField! @IBOutlet var numberLabel: UILabel! let minNumber = 1 var seisuu:Int = 1 { didSet { if seisuu < minNumber { seisuu = minNumber } } } @IBAction func button(_ sender: Any) { seisuu = Int(numberTextFIeld.text!) ?? 0 numberLabel.text = String(seisuu) } }実行結果
変数seisuuに数字がセットされた時に、最低値以下の場合は最低値になるように指定しているので、ボタンを押した時に1以下の数字は1と表示する事ができた。
メリット
これらを使用する事で、コードがかなりコンパクトになります。
別のフィールドを作成する事なく、値を保持して取り出す事が出来ます。参考文献
https://qastack.jp/programming/24006234/what-is-the-purpose-of-willset-and-didset-in-swift
荻原 剛志、『詳解 Swift 第5版』、SBクリエイティブ株式会社、2019年11月25日、557ページ
- 投稿日:2021-01-06T17:59:48+09:00
UILabelの幅と高さを自動で設定する
はじめに
SnapKitを使いコードベースでレイアウトを組むようになってからテキストを持つUI部品の幅や高さを意識するようになりました。ここではwidthやheightを自動で設定する方法について記録を残します。
悩み
コードベースでレイアウトを組むようにしたものの、width
とheight
の設定が上手くいかない!というかメンドくさい!private func setupLabel() { label.text = "あいうえお" label.backgroundColor = .red view.addSubview(label) label.snp.makeConstraints { $0.width.equalTo(60)//ここを定数にしてしまっているため、いい感じになるまで何度も確認しなければならない $0.height.equalTo(30)//同様 $0.center.equalToSuperview() } } }解決方法
sizeThatFits
とgreatestFiniteMagnitude
を使います。private func setupLabel() { label.text = "あいうえお" label.backgroundColor = .red let size = label.sizeThatFits(CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) view.addSubview(label) label.snp.makeConstraints { $0.width.equalTo(size.width) $0.height.equalTo(size.height) $0.center.equalToSuperview() } }
- 投稿日:2021-01-06T17:43:16+09:00
Swiftのダイアログの基本の基
Swiftのダイアログの基本の基
非エンジニアで最近Swiftを勉強している友人に「ダイアログって何?どう構築するの?」と質問された際を想定し、
ダイアログの基本についてまとめます。※主に自身の毎日の復習・学習の機会創出、アウトプットによる知識の定着を目的としております。
暖かい目で読んで頂けますと幸いです。ダイアログとは
ユーザーに入力を求めたり、何かの通知を行うために表示される小さなウィンドウ「ダイアログボックス」の略称。
例えば、ログアウトの際にログアウトボタンを押した後、「本当にログアウトしますか?」(選択肢:「はい」or「いいえ」)などが表示画面の上にぽっと表示されるあれです。
実装コード
まずは基本のコードを記載します。
※説明は後半に記載します。//今回はdisplayDialogメソッドとして作成します。 func displayDialog(){ //ここからが本題 //===================== //STEP1 //ダイアログのインスタンスを設定。 //タイトルとメッセージをString型で設定 //preferredStyle後ほど説明 //===================== let mDialog = UIAllertControlle(title: "タイトル", message: "メッセージ内容", preferredStyle: .alert) //===================== //STEP2 //選択肢(ボタン)を作成 ※今回は一つだけ //タイトルとスタイルを選択 ※スタイルについては後述 //===================== mDialog.addAction(UIAlertAction(title: "ボタンのタイトル", style: .default, handler: { action in //ここにタップ時の動作を記入 })) //===================== //STEP3 //ダイアログを表示 //===================== self.present(mDialog,animated: true,completion: nil) }STEP1 ダイアログのインスタンスを設定+各種値を入力
まずはダイアログ「AlertController」のインスタンスを作成します。
引数として「title」「message」「preferredStyle」が必要です。title
String型でダイアログのタイトルを入力します
message
String型でダイアログに表示する本文を入力します
preferredStyle
.alert : 画面の中央に表示させる
.actionSheet : 画面下部に下からせりあがる形式で表示させる。STEP2 ダイアログのボタンを設定
.addActionで用いてダイアログに選択肢(ボタン)を設定する。
UIAlertActionで選択されたときのアクションなどを設定する。
引数として「title」「style」「handler」が必要です。title
String型でボタンのタイトルを入力します
style
Default : 通常の選択肢
Destructive : 赤字表示(※否定的な選択肢を表示する際に利用)
Cancel : 一番下に表示されるかつ一つしか表示できないhandler
タップ時の動作を記入
STEP3 ダイアログを表示
presentを用いて表示
おまけ
ダイアログについては、SCLAlertViewと言う簡単にカッコ良いアニメーションのついたダイアログを表示できるライブラリがあります。
そちらを利用してみても良いかもしれません。[Swift] 簡単に、かっこいいアニメーションアラートが表示できる、SCLAlertViewについて
まとめ
このようにまとめてみると、ダイアログは非常に簡単ですが、一つ一つのオプションなどの選択肢もあり、用途別でいろいろ調整できることがわかりました。一旦アウトプットする手を止めて、基本部分でも体系的な知識を学ぶメリットは大きいですね。
普段はDefaultとかがあればそれで良いかと何も考えず利用してしまうので、今回のように指定できる選択肢を知ることの必要性をひしひしと感じております。
利用用途に合わせて今後細かい設定もしていきたいと思います。
- 投稿日:2021-01-06T09:20:07+09:00
[RxSwift]combineLatestについて
Rxの学習過程で学んだことを後からでも見れるようにメモしていきます
本記事はこちらRxSwiftを参考にしていますCombineLatestとは
Observable
シーケンスのいずれかが要素を生成するたびに、指定されたObservable
シーケンスを、1つのタプルのObservable
シーケンスにマージする。使用例
各
TextField
に入力した値をリアルタイムで合計し、ラベルに表示する。
といった機能を作っていきます。数値入力用の
TextField
を3つと合計結果表示用のLabel
をひとつ用意しいます。Observable.combineLatest(textField1.rx.text.orEmpty, textField2.rx.text.orEmpty, textField3.rx.text.orEmpty) { textValue1, textValue2, textValue3 -> Int in return (Int(textValue1) ?? 0) + (Int(textValue2) ?? 0) + (Int(textValue3) ?? 0) } .map { $0.description } .bind(to: label.rx.text) .disposed(by: disposeBag)結果