😩

iOSカスタムキーボードのsetMarkedTextの現状

3 min read

iOSカスタムキーボード開発者は「setMarkedText」という関数の修正を待ち望んでいます。この関数はサードパーティ製のキーボードアプリにおいて未確定文字列をハイライトする機能を実現します。

iOS12まで:そもそもsetMarkedTextできない

大手キーボードアプリのレビュー欄を見渡すと一つは必ずあるのがこの「未確定文字列の背景に色がつくようにしてほしい」という要望です。私も自分のキーボードアプリを公開してから何人かに言われました。実際、iOS標準のキーボードではずっと前から用いられている仕組みなのです。

iOS13まではsetMarkedTextはそもそもなかったので、開発者は「無理なんですよ・・・」というほかありませんでした。

iOS14.3まで:入力中文字列に触れない

この関数はiOS13から追加され、サードパーティ製のキーボードアプリにおいても未確定文字列をハイライトする機能(=marked text)を可能にしました。当初かなり嬉しいアップデートだったのですが、現状大手のキーボードアプリ(Simeji, Gboard, ATOKなど)はほとんど用いていません。唯一flickがオプションとして提供している状態です。

これほどまでに用いられていないのにはそれなりの理由があります。iOS14.3までのsetMarkedTextでは、ハイライトした文字列に触れると文字列が消えてしまいました。これは見て明らかなバグであり、回避も不可能でしたから、実装を避けるのは半ば当然でした。

iOS14.4から:ユーザの操作が検知できない

誰から見ても明らかなバグだったので「タップすると消えちゃうよ」とフィードバックを送っていたのですが、先日「Xcode12.4 + iOS14.4で直ってない?」という返信が来たので確かめてみたところ直っていました

そこでついに実用できるか?と思って本格的に挙動を確かめたのですが、山積みの問題点が明らかになりました・・・。

そもそものsetMarkedTextの挙動

setMarkedTextは次のように用います。

let string = "markedtext1"
let range = NSRange(location: string.utf8.count, length: 0)
self.textDocumentProxy.setMarkedText(string, selectedRange: range) //<markedtext1|>  (|はカーソル位置、<>はmark部分)

変数rangeは選択部分を代入します。つまりこのコードでは文字列の最後の部分から0文字に渡って選択するので、カーソルは末尾に来るわけです。

let string = "markedtext2"
let range = NSRange(location: 0, length: 0)
self.textDocumentProxy.setMarkedText(string, selectedRange: range) //<|markedtext2>
let string = "markedtext3"
let range = NSRange(location: 2, length: 0)
self.textDocumentProxy.setMarkedText(string, selectedRange: range) //<ma|rkedtext3>

marked textに対する「1文字削除」を実行するには次のようにします。

//最初の状態は<markedtext1|>
let string = String("markedtext1".dropLast())
let range = NSRange(location: string.utf8.count, length: 0)
self.textDocumentProxy.setMarkedText(string, selectedRange: range) //<markedtext|>

見ての通り、単に1文字減らした文字列を再びセットするだけです。
したがって、setMarkedTextを前提にしたキーボード開発では、内部でカーソルと入力中文字列の状態を持ち、その情報に合わせて「削除」「文字の追加」「カーソルの移動」などに対応した操作で更新した文字列をsetし直していく必要があります。
これはsetMarkedTextのないキーボード開発でも全く同様でしたから、この部分の機構は既に用意している開発者も多いでしょう。

ユーザ操作が検知できない

setMarkedTextのないキーボード開発ではtext{Will|Did}Change及びdocument{Before|After}ContextselctedTextが鍵でした。しかしmarked textが存在する状況ではこのメソッドは呼ばれませんし、プロパティはあたかもmarked text全体が一つのカーソルであるかのように振る舞います。またmarked text部分の状態を取得することは不可能です。

ところがユーザは文字を選択できますし、カーソルを移動できます。しかしmarked textがある状態ではこれらのユーザの操作を検知する機構が使えなくなるので、キーボードの内部状態を更新できません。つまり「カーソルは左端にあるように見えるのに、右端に入力される」というような状態になってしまうのです。

その他バグ

いまだに挙動が不安定です。

  • marked textが存在している状態でadjustTextPositionを呼ぶとmarked textが消されたような状態になりますが、複数回繰り返して呼ぶと再び現れたりします。カーソル移動はrangeの方を変えて行えるでしょうからそこまで致命的ではありませんが。

  • setMarkedTextしてからdeleteBackwardすると表示上は消えるのにtextDocumentProxyには残ります。そのまま作業を続けていると突然復活したりします。

  • なお、WebKitのテキストフィールド上で行うと全く違う結果になります。この挙動の違いは本当に致命的。

今後の方向性

上記のバグや致命的な挙動はすでにフィードバック済みです。対応は遅いとはいえバグは着実に修正されているので、進捗を期待して待っています。iOS15が来る頃には直っていると嬉しいですね。

setMarkedTextの挙動を簡単に確認するためのサンプルプロジェクトを作ってあります。setMarkedTextについて何かバグを見つけたらIssuesに追加したりAppleに報告したりして頂けると嬉しいです。日本語で大丈夫です。

https://github.com/ensan-hcl/ExploreSetMarkedText

Discussion

ログインするとコメントできます