iOS Keyboard Extensionでキーボードを介さない操作を検知する


iOSの純正キーボードは超絶多機能です。ちょっと考えただけでもこんな機能がついています。

  • 入力中の文字を薄い青色(ダークモードでは黄色)でハイライトする。
  • 点滅するカーソルをドラッグすることによるカーソルの移動を検知し、変換候補を変更する。
  • 点滅するカーソルをドラッグすることによるカーソルの移動を検知し、入力中の範囲から外に出られないようにする。
  • 入力範囲外をタップした場合入力中の文字を確定する。
  • 選択されているテキストを取得し、再変換する。
  • ペースト操作が行われた場合検知し、入力中の文字を確定する。

これは高度な機能です。実際私が日本語対応のkeyboard extensionを漁った限り、純正キーボードと同じ挙動を再現できているものはほとんどありませんでした。

そこでこれらの機能をできる限り実現すべく、いろいろ努力した結果をまとめます。

入力中の文字のハイライト

いきなり残念なお知らせですが、これは今は断念するのが正解です。
この機能の実現にはsetMarkedText()というメソッドが利用できます。が、これを実現すると他の無数の機能が死にます。
詳しくはこちらを参照してください:UITextDocumentProxyのsetMarkedTextを(まだ)使ってはいけない。 - Qiita

それ以外

  • カーソル移動検知(→カーソル移動制限など)
  • ペースト検知
  • 範囲外タップ検知
  • 選択検知
  • 選択解除検知
  • カット検知

などがどうにかできました。
大体、documentContextBeforeInputdocumentContextAfterInput、それにselectedTextをゴリ押しで取得していくとどうにかなりました。UIInputViewControllertextWillChangetextDidChangeは、引数のtextInputは使えませんが、少なくとも私の環境では一応呼ばれるのでこれを利用します。
documentContextBeforeInputは日本語入力ではカーソルの左側の1文、documentContextAfterInputは右側の1文を取得します。選択部分が存在する場合には両端がそれぞれカーソルとみなされる挙動のようです。したがってテキスト全体はdocumentContextBeforeInput+selectedText+documentContextAfterInputで得られます。

仕様(2020/12/16追記)

にも書きましたが、1文とは「句点」「ハテナ」「ピリオド」などを末尾にもつか、末尾が文章の終端になっているものです。このとき文末の右側にカーソルがある場合、つまり「アイウエオ。カキクケコ。|サシスセソ。タチツテト。」のような位置では、beforeに「カキクケコ。」が、afterに「サシスセソ。」が入ります。

また、selectedTextは複数文選択している場合に例外的な挙動を示すことがあります。

改行周り

改行も文の区切りとして認識されますが、さらにもう少し挙動が複雑です。

left/center/rightとして得られる情報は以下の通り
|はカーソル位置。二つある場合は選択範囲
    |はカーソル位置。二つある場合は選択範囲
     ---------------------
        |abc              :nil/nil/abc
     ---------------------
        abc|def           :abc/nil/def
     ---------------------
        abc|def|ghi       :abc/def/ghi
     ---------------------
        abc|              :abc/nil/nil
     ---------------------
        abc|              :abc/nil/empty

     ---------------------
                          :\n/nil/def
        |def
     ---------------------
        abc|              :abc/nil/empty
        def
     ---------------------
        abc
        |def              :\n/nil/def
     ---------------------
        a|bc
        d|ef              :a/bc \n d/ef
     ---------------------

まとめていうと、
1. left\nが含まれることはない。右端にカーソルをおいた場合、次の行が存在していれば空文字列""、存在していなければnilが得られる。
2. rightには\nが含まれうる。左端にカーソルをおいた場合、前の行が存在していれば改行\n、存在していなければnilが得られる。左端以外の位置では\nは取り除かれる。
3. centerは両端のカーソルについてそれぞれleft rightと同様の挙動を示す。
です。

実装

まず、ViewController側でなんらかの変化が起こる前に現在の状態を登録、起こった後に変化後の状態を登録します。

KeyboardViewController.swift
class KeyboardViewController: UIInputViewController {
    override func textWillChange(_ textInput: UITextInput?) {
        // The app is about to change the document's contents. Perform any preparation here.
        super.textWillChange(textInput)

        let left = self.textDocumentProxy.documentContextBeforeInput ?? ""
        let center = self.textDocumentProxy.selectedText ?? ""
        let right = self.textDocumentProxy.documentContextAfterInput ?? ""
        registerSomethingWillChange(left: left, center: center, right: right)
    }

    override func textDidChange(_ textInput: UITextInput?) {
        // The app has just changed the document's contents, the document context has been updated.
        super.textDidChange(textInput)

        let left = self.textDocumentProxy.documentContextBeforeInput ?? ""
        let center = self.textDocumentProxy.selectedText ?? ""
        let right = self.textDocumentProxy.documentContextAfterInput ?? ""
        registerSomethingDidChange(left: left, center: center, right: right)
    }
}

で、適当なところに次の二つの関数を書いておきます。まず変化前の状況は保存します。

registerSomethingWillChange.swift
func registerSomethingWillChange(left:String, center:String, right:String){
    self.tempTextData = (left:left, center:center, right:right)
}

変化が起こった場合、変化前と変化後の状態を比較することで状況を判断します。このロジックは愚直に実装しました。私の知る限りこういう諸々の動作を検知するための機構はUIKitでは提供されていません。

registerSomethingDidChange.swift
func registerSomethingDidChange(left:String, center:String, right:String){
    //leftは変化後のtextDocumentProxy.documentContextBeforeInput
    //centerは変化後のtextDocumentProxy.selectedText
    //rightは変化後のtextDocumentProxy.documentContextAfterInput
    let b_left = self.tempTextData.left      //変化前のleft
    let b_center = self.tempTextData.center  //変化前のcenter
    let b_right = self.tempTextData.right    //変化前のafter

    let isWholeTextChanged = !((left+center+right) == (b_left + b_center + b_right)) //全体が変化しているか?
    let wasSelected = !(b_center == "") //選択されていたか?
    let isSelected = !(center == "")    //選択されているか?

    //全体としてテキストが変化せず、選択範囲が存在している場合→新たに選択した、または選択範囲を変更した
    if !isWholeTextChanged && isSelected{
        //なんらかの操作をする。例えば再変換したい場合はcenterの値を用いて変換候補を表示する。
        return
    }

    //全体としてテキストが変化せず、選択範囲が無くなっている場合→選択を解除した
    if !isWholeTextChanged && wasSelected && !isSelected{
        //なんらかの操作をする。例えば再変換の候補の表示を消す。
        return
    }

    //全体としてテキストが変化せず、選択範囲は前後ともになく、左側(右側でも良い)の文字列が変わっていた場合→カーソルを移動した
    if !isWholeTextChanged && !wasSelected && !isSelected && b_left != left{
        //カーソルの移動を処理する。例えば移動範囲が入力中の範囲を超えていた場合はadjustTextPositionなどを用いてカーソルを補正する。
        return
    }
    //それ以外の状況で全体のテキストに変化がなければ、検出の必要はおそらくない。
    if !isWholeTextChanged{
        //なんらかの操作
        return
    }

    //全体としてテキストが変化しており、左は改行コードになっており、かつ前のwholeText(=left+center+right)と後の選択範囲が一致する場合→行全体が選択された
    if isWholeTextChanged && left == "\n" && b_left + b_center + b_right == center{
        //行全体の選択を検知する。
        return
    }

    //全体としてテキストが変化しており、前の左は改行コードで、かつ前のcenterと後のwholeTextが一致する場合→行全体の選択が解除された
    if isWholeTextChanged && b_left == "\n" && b_center == left + center + right{
        //行全体の選択解除を検知する。
        return
    }

    //全体としてテキストが変化しており、左右の文字列を合わせたものが不変である場合→ユーザが選択部分をカットした。
    if isWholeTextChanged && b_left + b_right == left + right{
        //カットを検知する。
        return
    }

    //全体としてテキストが変化しており、右側の文字列が不変であった場合→ペーストが疑われる。
    if isWholeTextChanged && b_right == right{
        //もしクリップボードに文字列がコピーされており、かつ、前の左側文字列にその文字列を加えた文字列が後の左側の文字列に一致した場合→確実にペーストである。
        if let pastedText = UIPasteboard.general.string, pastedText == left.suffix(pastedText.count){
            //なんらかの操作
            return
        }
    }

    //上記のどれにも引っかからず、なおかつテキスト全体が変更された場合→範囲外タップ。
    if isWholeTextChanged{
        //範囲外タップを検出し、例えば確定する。
        return
    }
}

まとめ

お読みいただいた通りで、Keyboard Extension周りはかなり気合が求められます。頑張りましょう。

余談

こんな記事を書いていたら、iOSの純正キーボードでもちょっと怪しい挙動を発見しました。
入力中にカーソルを真ん中あたりまで移動し、その上で入力範囲外をタップするとカーソルの後の部分が全て消えて確定扱いになります。一方入力中にカーソルを真ん中あたりまで移動し、さらに文字を入力、または消去する操作を行ってから入力範囲外をタップすると単に確定扱いになります。

あまり自然な挙動とは思えないので、バグの可能性が高いと思います。Appleの標準キーボードの開発者も相当苦労していそうです。