💬

iOSアプリのキーボードショートカットがシステムと競合しているときに上手いことやりたい

2024/02/28に公開
1

iOSアプリであっても、iPadで使う際はキーボードショートカットが設定されていると便利です。
UIKeyCommand を使うことで、カスタムのキーボードショートカットが設定できます。

https://developer.apple.com/documentation/uikit/uikeycommand

UIKeyCommand の基本的な使い方は他の記事を参照していただくとして、
今回の記事では実際に使ってみて苦戦したポイントをあげていきます。

対象のアプリは、ブロック形式のテキストエディタです。
システムのキーボードショートカットに、ブロック間の移動を追加したい!というモチベーション。
NotionのiOSアプリみたいな動きにしたい、と思ってください。

システムのキーボードショートカットとの競合

iOS 15以降の変更ですが、iOSで割り振られてるキーボードショートカットがある場合、カスタムのキーボードショートカットは無視されます。
wantsPriorityOverSystemBehaviorというプロパティをtrueにすることで、
カスタムを優先させることができます。

競合したキー

矢印キーとTabキーはシステムキーボードショートカットキーと競合します。

左右のカーソル移動

左右のカーソル移動は、下記のように実装することで動かせます。
(UITextViewの利用を想定)

private func moveCursor(offset: Int) {
    textView.selectedRange.location += offset
}

これは楽ですね。

(追記)
ただ、これ文字選択された状態でも、←→がカーソル移動になるので、不自然な挙動になります。
文字選択状態のときの←→は、文字選択キャンセルなんですね。

上下のカーソル移動

上下のカーソル移動が厳しいです。
UITextView 内部で複数行あった場合、「↑」キーを押すと、次の挙動になります。

aa
a|a
a|a
aa
|aa
aa

この挙動が自分で実装しようとすると、エグくてですね……
当初、以下の2方針を考えました。

  • システムとカスタムショートカットキーを切り替える
  • 行あたりの最大文字数を計算して、カーソルを移動させる

システムとカスタムショートカットキーは切り替えられない?

カーソルがブロック内の0文字目にあったらカスタム、それ以外だったらシステムショートカット、みたいな分岐が可能なら良かったんですが、
開発者側からシステムショートカットの処理を呼び出す方法はありませんでした。

なので、やるとしたら、カーソルが0文字目になったタイミングで、UIKeyCommand を設定しなおすみたいになると思うんですが、
カーソル移動のイベントが取れなくて、挫折しました。

(追記)
これ、後にやる方法わかったので、最後に書きます。

行あたりの最大文字数を計算して、カーソルを移動させる

もうカスタムショートカットキーでシステムの動きをするしかない、んですが、これも茨の道でした。
Viewのwidthとフォントサイズから、行単位の最大文字数は計算できます。
なので、textView.selectedRange.location を使って、標準っぽい上下のカーソル移動がある程度は可能です。

ある程度、というのは、UITextFieldの改行位置が必ずしも最大文字まで来ないときがあるためです。
改行にも2パターンあって、改行の制御文字が入っている場合と、入ってない場合に親切で改行を入れる場合とがあります。
UITextFieldの親切な改行は、とても親切なので、禁則処理などをきちんとやってくれます。

https://ja.wikipedia.org/wiki/禁則処理

たとえば

あああ!!!!!

こんな文があったとします。
これに文字を増やしていって、「!」記号が次の行に折り返されたとします。

ああああああああああああ
!!!!!

すると、UITextFieldは親切で禁則処理をしてくれて、!記号が文頭に来ないようにしてくれます。

あああああああああああ
あ!!!!!

すばらしい挙動なんですけど、今回の場合は扱いに困ります。

TextKit 2使うといける?

TextKit 2は行単位でのレイアウトが取れるっぽいので、これでやる道もあるのかと思ったんですが、
ちょっとどう実装したらいいのかよくわからなかったです。

https://speakerdeck.com/niw/iosdc-japan-2023

システムとカスタムショートカットキーを切り替える

以上、つらつら書いてきたように、自前で文字の上下左右移動を実装しようとすると、どうやってもUIKit標準の動作より劣化することになります。
「カーソルがUITextViewの先頭or末尾の時だけカスタムショートカットキーを有効にさせる」という方針が良さそうです。

ショートカットキーを設定したViewControllerのcanPerformAction()メソッドで明示的にfalseを返してあげると、
標準のショートカットキーが有効になることがわかったので、最終的にそれで実装しました。

static let upKeyCommand = UIKeyCommand(action: #selector(moveUp(sender:)), input: UIKeyCommand.inputUpArrow, modifierFlags: [], discoverabilityTitle: "前へ")

override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    if supportedKeyCommands.first(where: { $0.action == action }) != nil {
        switch action {
        case Self.upKeyCommand.action:
            return isCursorFirst
        // …
        default:
            return true
        }
    } else {
        return super.canPerformAction(action, withSender: sender)
    }
}

てっきりfalse返しちゃうと、入力されたキーに対して何もしないで終わると思っていましたが、標準の動作になるようです。
ドキュメント読んだ感じだと、そうならなそうだったんですが。

カーソル位置の判定の注意点

また、カーソルがUITextViewの先頭or末尾にあるかどうかは、下記のように判定してます。

private var isCursorFirst: Bool {
    if textView.selectedTextRange == textView.textRange(from: textView.beginningOfDocument, to: textView.beginningOfDocument)
    {
        return true
    }
    return false
}

private var isCursorLast: Bool {
    if textView.selectedTextRange == textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument)
    {
        return true
    }
    return false
}

当初は単にtextViewの文字数使って判定してたんですが、それだと絵文字入ったときなどに微妙にズレが発生するようで、
textView.textRange(from: textView.endOfDocument, to: textView.endOfDocument)だとより正確に判定してくれるようです。
UITextPositionとかUITextRangeとかを使うのはコード的には難読になるので嫌だったんですが、
Intだといくつかのケースで不具合が出るっぽいです。

There are two reasons for using objects for text positions rather than primitive types such as NSInteger:

  • Some documents contain nested elements (for example, HTML tags and embedded objects) and you need to track both absolute position and position in the visible text.

  • The WebKit framework requires that text indexes and offsets be represented by objects.

(出典)

カーソル移動のイベント

「カーソル移動のイベントが取れなくて、挫折しました」と上で書いたのですが、これ良く調べたら取れました。
textViewDidChangeSelection(_:)がカーソル移動のたびに呼ばれるので、これで取れます。

メソッド名的に選択範囲が変わったときに呼ばれるDelegateメソッドだと思ってたのですが、
どうやらカーソルの移動も選択範囲の変更に分類されてたようです。

これを発見したので、addKeyCommand()removeKeyCommand()使って、
カーソルが先頭・末尾に移動したタイミングでカスタムキーボードショートカットを追加したり削除したりをやる実装にしました。
その後でコードレビューで「canPerformAction()で切り替えられそうだよ」というアドバイスをもらって、最終的にそっちにしました。

add/remove繰り返すでもできますが、ちょっとしんどいですね。
canPerformAction()でやる方がスマートだと思います。

(了)

Discussion