20210106のSwiftに関する記事は5件です。

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.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が呼ばれる PressesBegan, pressed=trueが呼ばれる 点灯
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
    }
}

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift] プロパティオブザーバ willSetとdidSet

プロパティオブザーバとは

格納型プロパティの値が更新される時、それをきっかけにして処理を書く事が出来ます。
例えば、プロパティオブザーバを使用する事で
var price = 100 の値を更新した時に、更新の回数をカウントする処理を書く事が出来たりする訳です。
このプロパティオブザーバには、値が変更される直前に呼び出されるwillSetと変更後に呼び出されるdidSetがあります。
これらはどちらか一方だけ書くこともできます。
書き方は以下の通りです。

var プロパティ名:  = 初期値 {
    willSet (仮引数) {
         変更直前に呼ばれる処理
   }
    didSet (仮引数) {
         変更直後に呼ばれる処理
   }
}

それぞれの仮引数

willSetdidSetにはそれぞれ仮引数を指定する事が出来ます。
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内で購入回数を取得し、価格差を計算して表示する事が出来ました。

スクリーンショット 2021-01-06 19.55.20.png

スクリーンショット 2021-01-06 19.55.44.png

スクリーンショット 2021-01-06 19.56.06.png

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と表示する事ができた。

スクリーンショット 2021-01-06 20.33.24.png
-30と入力しても1と表示する。↓
スクリーンショット 2021-01-06 20.33.46.png

メリット

これらを使用する事で、コードがかなりコンパクトになります。
別のフィールドを作成する事なく、値を保持して取り出す事が出来ます。

参考文献

https://qastack.jp/programming/24006234/what-is-the-purpose-of-willset-and-didset-in-swift

荻原 剛志、『詳解 Swift 第5版』、SBクリエイティブ株式会社、2019年11月25日、557ページ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UILabelの幅と高さを自動で設定する

はじめに

SnapKitを使いコードベースでレイアウトを組むようになってからテキストを持つUI部品の幅や高さを意識するようになりました。ここではwidthやheightを自動で設定する方法について記録を残します。

悩み

Simulator Screen Shot - iPhone 12 - 2021-01-06 at 17.53.19.png
コードベースでレイアウトを組むようにしたものの、widthheightの設定が上手くいかない!というかメンドくさい!

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()
        }
    }
}

解決方法

sizeThatFitsgreatestFiniteMagnitudeを使います。

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()
        }
    }

Simulator Screen Shot - iPhone 12 - 2021-01-06 at 17.57.47.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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とかがあればそれで良いかと何も考えず利用してしまうので、今回のように指定できる選択肢を知ることの必要性をひしひしと感じております。

利用用途に合わせて今後細かい設定もしていきたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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)

結果

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む