UITextView のバグと対策
iOS 7 より UITextView の挙動が著しくおかしいということについて「iOS 7 のテキスト入力欄(UITextView)の問題について」に書いた。iOS 8 においても UITextView のバグは直されなかった。
iOS 9 が発表され、SDK をいじってみて、なんとなく直っているような気がしたが、それは気のせいだった。全体的には少しずつ改善されているように思うが、依然として振る舞いが安定しない。特に、カーソルがスクロール外の位置にある時に、ビューをカーソルが表示されるところまでスクロールさせるメソッドがうまく機能しないので、カーソルを見失ったような状態になりますい。この現象は純正アプリも含めて、UITextView を使った色々な箇所で見受けられる。
私がプロダクトマネージャーをつとめる Textwell の iOS 版では、UITextView のバグを回避するための補正処理として、具体的に、以下のメソッド/プロパティをオーバーライドしている。
・scrollRangeToVisible:
カーソルが見える位置までビューをスクロールするメソッドだが、テキストレンジの座標が正しく計算されていないようなので、自前でそれを計算し直し、”scrollRectToVisible:animated:” に投げる。
・closestPositionToPoint:
特定の座標に一番近いテキストポジションを返すメソッド。タッチジェスチャとテキスト処理の橋渡しをする重要なメソッドだが、時々おかしな値を返してくるので、レイアウトマネージャーのグリフ情報を使って正しいポジションを返すようにする。
・layoutManager.allowsNonContiguousLayout
デフォルトでは NO だがこれを YES にする。
・layoutManager.hyphenationFactor
デフォルトで 0 のはずだが、何かの拍子に値が変わるのかもしれない。0 に指定し直すと挙動がましになる気がする。
これらを行うことで、概ね iOS 6 までの UITextView と同じ挙動になる。
Textwell v1.4 までのジェスチャ
ここから本題。
iOS 9 をターゲットにリリースした Textwell v1.5 では、テキストビューのタッチジェスチャをすべて書き直したのだが、そもそも v1.4 までに実装していたジェスチャは次のようなものだった。
- テキスト編集状態(キーボードが出ている状態)の時に、1本指で水平方向にスワイプすると、カーソルが指の動きに従って移動する。
- 一度カーソルが動きだすと、指を縦方向にも(全方向)動かしてカーソルを上下左右に移動できる。
- 二本指によるピンチ、もしくは一本指でのカーソル移動中に二本目を追加してピンチ操作で、選択範囲の作成および範囲変更。
- 二本指による横方向スワイプでテキスト全消去。
テキストビューの上で指を動かすことでカーソルを全方向に(指の移動とシンクロして)移動できるというのは、Textwell のひとつの特徴だった。Textwell がこのジェスチャを実装する以前にも、例えばスクリーンキーボード上の矢印キーを使ったり、テキストビューの上をスワイプすることで、カーソルを前後に移動できるエディターアプリはあった。あるいはスクリーンキーボードに加えられた特別なキーをトラックポイントのようにしてそこからドラッグして上下左右方向にカーソルを移動できるアプリもあった。しかしそれらはいずれも、カーソルの位置を特定の方向に文字単位で移動するもので、一文字ずつ左右や上下にカーソルがジャンプするような間接的な動きになっていた。指の動きに直接対応するものではなかった。
Textwell では、指が右に動いたらカーソルを一文字右に動かす、といった実装ではなく、指の軌跡をそのまま反映する仮想カーソルポイントを現在のカーソル位置から出発させ、その仮想カーソルポイントに一番近い挿入ポイントにカーソルを持っていくという方法をとった。これにより、マウスでポインターを動かす時のような直接操作感が高まった。
二本指による消去ジェスチャは、これは妥協的な実装だった。もとは一本指による横方向スワイプをテキスト全消去のジェスチャとしていた。テーブルビューの標準的な操作として、アイテムを横スワイプすると消去ボタンが現れる。これを踏襲したデザインだった。しか上記のカーソル移動のジェスチャを実装したため、一本指スワイプはカーソル移動に使うので、しかたなく二本指によるスワイプとしたのだった。それにより、片手では行えないジェスチャになってしまった。
iOS 9 における標準のテキスト編集ジェスチャ
iOS 9 では、UITextView に新たなジェスチャが加わった。
iOS はメジャーバージョンアップのたびに標準ジェスチャが追加されるので、アプリ側でそれまで実装していた独自ジェスチャとコンフリクトすることがよくある。標準ジェスチャの多くはアプリ側でキャンセルしたりオーバーライドしたりできないため、その場合はアプリ側がジェスチャの仕様を変更せざるを得なくなる。
API として自由にジェスチャを実装できるようになっているにもかかわらず、それを台無しにしてしまうようなグローバルなジェスチャを次々と追加してくる Apple のやり方も問題だと思うが、iOS 9 におけるさらなる問題は、iPhone と iPad で同じ機能を異なるジェスチャで実装してきたところにある。
iOS 9 + iPhone 6s における新しいテキスト編集ジェスチャ
iOS 9 + iPhone 6s/ 6s Plus の UITextView では、次のようなジェスチャが加わった。マニュアルにさらりと書いてあるだけのものもあるので、使っている人でもあまり把握していないかもしれない。
- スクリーンキーボードを強押しすると、キーボードがグレーになり、その状態から指を動かすと、全方向にカーソルを移動できる。
- カーソル移動状態の時に更に強く一回押すと、(現在カーソルがある位置を基準に)単語選択になる。二回押すと文選択、三回押すと段落選択となる。
- 上の方法で選択範囲を作った直後にドラッグすると、選択範囲の変更ができる。
この二段階の強押しは、加減が難しく、かなり練習しないとうまくできるようにならない。また日本語文字列においてはあまり意味をなさない。またマニュアルには書いていないが、キーボードの強押しからではなく、文字列の上を直接強押しすることでも、単語選択、文選択、段落選択はでき、むしろその方が操作は簡単だ。
もちろん iOS 9 であっても、3D Touch に対応した最新機種でなければこれら強押しを用いたジェスチャは使えない。また、iOS 8 までにあった標準ジェスチャのうち、複数回タップや二本指タップで(そのポジションが含まれる)行や段落を選択する機能はオミットされたようだ。
iOS 9 + iPad における新しいテキスト編集ジェスチャ
一方、iOS 9 + iPad の UITextView では、次のようなジェスチャが加わった。こちらもマニュアルにさらりと書いてあるだけのものもあるので、使っている人でもあまり把握していないかもしれない。
- スクリーンキーボードを二本指で長押しすると、キーボードがグレーになり、その状態から指を動かすと、全方向にカーソルを移動できる。(とマニュアルには書いてあるが、実際には、キーボード上でなくても、テキストビューの上で二本指でスワイプすれば同じ状態になる)
- 二本指でタップすると、(現在カーソルがある位置を基準に)単語選択、ダブルタップで文選択、トリプルタップで段落選択になる。
- 選択範囲があるとき(選択範囲を作った直後でなくても)、二本指でスワイプすることで選択範囲を変更できる。
このように、iPhone における強押しジェスチャと同じようなことが二本指ジェスチャでできるようになっているが、例えば選択範囲の変更のしかたなど、細かいところが異なる。
また例えば、現在カーソルがある場所から離れたところを二本指タップすると、以前であればタップした場所が行選択されていたのに対し、iOS 9 では直前のカーソル位置を基準に単語選択になってしまう。ユーザーはこれまでの経験から、タップした場所にカーソル/選択範囲が移動すると期待するだろうから、この挙動は理解できない。自分がフォーカスしている場所とは違うところでなぜか文字が選択状態になっているように感じる。
iOS のフレームワークはここ数年、iPhone 用の画面と iPad 用の画面を同じUI部品で両立させるような作りになってきているのに、UITextView に関して、iPhone と iPad で違うジェスチャを実装してきたのは不思議だ。3D Touch のために新しいジェスチャ(フリーカーソル移動)を加えたのはわかるが、それと同様の動きを iPad では二本指スワイプで実装している。これは iPad Pro の広い画面でもカーソル移動をしやすくするという意図があったのだろうと思われるが、別の言い方をすれば、iPad では今後とうぶん 3D Touch を採用するつもりはない(だから別のジェスチャを用意した)というふうに考えられる。このような恣意的とも言えるデザイン判断は、まったく Apple らしくない。
ところで、iPhone における強押しにしろ、iPad における二本指スワイプにしろ、Apple が実装してきたフリーカーソル移動は、Textwell が実装していたものとかなり近い動きになっている。すなわち、指の動きをそのまま反映してカーソルが移動する。
ひとつ大きな違いは、Apple の実装では、カーソル移動中、カーソルが二つ表示されるところだ。ひとつは実際の挿入ポイントを表すグレーのカーソル、もうひとつは、文字の切れ目に関係なく指の動きを実際にトラッキングしている青いカーソルだ。前者は Textwell がやっていたカーソル移動と同じもので、後者は、Textwell でも内部的には存在していた、指の軌跡をそのまま反映する仮想カーソルポイントにカーソルを表示しているということ。
はじめ、なぜカーソルが二つ表示されるのか分からなかったが、その理由はおそらく、実際の挿入ポイントにだけカーソルを表示すると、短い行のところなどで、指の動きから乖離してカーソルがジャンプするように見えてしまうので、直接操作感を高めるためのフィードバックとして、指の動きをそのまま正確に現すオブジェクトがあるべきだと考えたのだろう。
その結果、はじめは意味がわからないものの、Textwell であったようにカーソルが文字と文字の間を断続的にジャンプするような不自然さが減り、Apple の実装ではカーソルがもっとスムーズに移動しているように見える。
もうひとつ、Apple のフリーカーソル移動が Textwell よりも良かったのは、Textwell ではカーソルの移動を横方向のスワイプで開始しなければならなかった(縦方向はテキストビュー自体のスクロールに使うので)のに対し、Apple の実装では、いきなり全方向にカーソル移動を開始できる点だ。
一方、Textwell で実装していた、ピンチ操作による選択範囲の作成および変更は、Apple は実装してこなかった。その理由はわからないが、操作として実用的でないと判断したのかもしれない。
Textwell v1.5 のジェスチャ
このような iOS 9 の新しい標準ジェスチャが追加されたことによって、Textwell ではもともと実装していたジェスチャの仕様を大きく変更することになった。なぜなら次のように考えたからだ。
- Textwell のデザインポリシーとして、OS標準の仕様を尊重する。OS標準の振る舞いをむやみにオーバーライドしたり、OS標準で提供されている機能を別のイディオムで重複提供しない。カーソル移動のジェスチャが標準で実装された以上、これまで独自に実装していたカーソル移動ジェスチャは廃止とするのが本来。
- Textwell のデザインポリシーとして、フォームファクターに直接関係した操作性の違いを考慮する場合を除き、iPhone と iPad ではできるだけ同じ操作方法を提供する。iPad の標準ジェスチャとして二本指によるカーソル移動が実装されたということは、Textwell で実装していた、ピンチ操作による選択範囲作成/変更機能、二本指横スワイプによるテキスト消去機能は、オミットしなければならず、それは iPhone 版にも適用しなければならない。
ところが問題は、標準ジェスチャとしての、iPhone と iPad の違いを、Textwell の新しい仕様としてどのように吸収すればよいかということだった。
まず、標準でカーソル移動のジェスチャが加わったからといって、Textwell にあったカーソル移動の機能を単純に消してしまったら、iPhone 6 以下を使っているユーザーにとっては(これまで便利に使っていたかもしれない)カーソル移動ジェスチャが無くなってしまう。だから標準ジェスチャとコンフリクトしない何らかの方法で独自のカーソル移動ジェスチャは残さなければならない。
一方、iPhone 6s におけるキーボード強押しのカーソル移動機能は、当然、3D Touch に対応していない iPad では実装できない。
そう考えると、iPad の標準である、二本指スワイプでカーソル移動をユニバーサルな標準と位置づけ直して、これを iPhone にも独自に提供するというのが、一番蓋然性のある判断だと思われた。実際には、iPhone の狭い画面で二本指スワイプをするのはそれほど便利ではないかもしれないが、慣れればそこそこ使えるし、いきなり全方向に動かせるというメリットも享受できる。
ということで、二本指スワイプを Textwell におけるカーソル移動の標準ジェスチャとして、iPad でも iPhone でも同じ操作でカーソル移動きるようにすることにした。そして iPhone 6s によるキーボード強押しと、iPad におけるキーボードからの二本指スワイプは、機種限定のオプションとして捉える。
二本指パンによるカーソル移動を iPhone 向けに実装
iPhone でも iPad と同じように二本指スワイプでフリーカーソル移動できるよう、実装を進めた。これは、可能なかぎり、iPad での振る舞いをそっくり iPhone 上で再現しなければいけない。そうでないと統一的な操作の実現にならないので。
まず、それまで一本指に反応させていたカーソル移動ジェスチャを、二本指に反応するように変更した。iPad では、二本指によるスワイプ(厳密には Pan ジェスチャ)を開始するとすぐに全方向にカーソルを動かせる。これを iPhone で実装してみると、二本指で縦方向に動かした時に、スクロールしてしまうことがわかった。iPad ではスクロールしないので、iPad では二本指スワイプがスクロールをロックしていることがわかる。そこで iPhone でも二本指スワイプを検知したらスクロールがロックされるようにした。これはある意味、OS標準の振る舞いを変更しないというポリシーに反するが、そもそも Apple が iPhone と iPad で振る舞いを変えているのが問題なので、ここは iPad の振る舞い(二本指のパン時にはスクロールをロックする)を iPhone にも適用すれば一貫性欠如の問題が解消されると考えることにした。
これだけなら簡単だったが、iPad での二本指カーソル移動の挙動では、カーソルを移動している最中に指を一本はなして一本指になっても、パン操作を継続できる。またその状態から再び二本指になってもカーソル移動が継続する。しかも、指をはなしたり再度追加したりした時に、カーソルが指の間隔分ジャンプするようなことはない。これを実現するには、仮想カーソルポイントを単純に二本指のどちらかに追従させるのではなく、二本指の間に仮想タッチポイントを生成して、片方の指がはなされても残った方の指との位置関係を保持し続ける必要がある。そうすることで、仮想カーソルポイントは仮想タッチポイントとの位置関係を保持しているから、指の増減しても不自然にカーソルがジャンプすることがなくなった。
次に、iPad における二本指カーソル移動の動きをよく観察すると、前述のように、カーソルが二つ表示されている。片方は実際の挿入ポイントを示すカーソルで、グレーになっている。もうひとつは指の動きをそのまま反映した仮装カーソルポイント上のカーソルで、青(厳密にはテキストビューの tintColor プロパティの色)で表示されている。これを実現するには、それまでプログラム内部の値でしかなかった仮装カーソルポイントに、実際にカーソル(に見える縦長の四角)を表示すればいい。カーソル移動ジェスチャが開始されると同時に、本物のカーソルと同じ形状の小さなビューを生成して、テキストビューの tintColor と同じ色で塗り、仮装カーソルポイントに配置する。そしてカーソル移動に伴ってそれを移動していく。同時に、実際のカーソル(挿入ポイントに表示されている)の色をグレーにする。ところがここに問題があって、カーソルの色というのは、テキストビューの tintColor を変更すれば変わるのだが、OSのバグで、テキストビュー自体を一度非表示にしないとカーソルの色が更新されない。ドロー関連のメソッドを色々いじってもバグを回避できなかったので、仕方なく、仮想カーソルと同様に、実際のカーソルと同じ形のビューを生成して、それをグレーで塗って、実際のカーソルの真上に重ねて配置することにした。これでなんとか、標準のダブルカーソルの表示と同じになった。
ここで更に iPad における二本指カーソル移動の動きを観察していると、もうひとつ興味深い演出があることに気づいた。二本指スワイプでカーソルを移動しはじめる時に、カーソル(仮想カーソルの方)が一瞬膨らむのだ。そしてカーソルを移動している間も、実際のカーソルよりも少しだけ大きく表示されていて、しかも少しだけ半透明になっている。この膨張演出は、カーソルの移動しはじめだけでなく、二本指をテキストビューに置いただけでも発生する。ということでこれも再現するために、二本指がテキストビューにタッチした時、もしくは二本指のパンジェスチャが開始された時に仮想カーソルが一度膨らんでから少し縮むアニメーションを実装し、また色の不透明度を下げて半透明になるようにした。単に不透明度を下げると色も薄くなってしまうが、標準の演出では色が薄くなっていないように見えたので、それを再現するために、不透明度を下げると同時に色の彩度を高める処理も加えた。これですっかり標準の表現が再現できた。
その他、iPad では、選択範囲がない場合に二本指を長押ししてからパンすると選択範囲を作成できる。選択範囲がある場合には、二本指のパンが選択範囲の変更になる。右または下方向にパンすると選択範囲の末尾のグラブポイントを移動している状態、左または上方向にパンすると選択範囲の先頭のグラプボイントを移動している状態になる。ということでこれも実装した。
一本指パンによるカーソル移動
これで iPhone でも iPad でも同じ二本指パンというジェスチャでカーソル移動および選択範囲の作成と変更ができるようになった。しかし、Textwell ではもともと一本指で(片手で)カーソル移動ができていたので、これだけだと iPhone 6s 以外の機種では前より不便になってしまう。けれども、カーソル移動のジェスチャは二本指としたので、以前のままの一本指カーソル移動のジェスチャを残すのでは重複感が大きい。特に iPhone 6s においては、二本指パン、一本指パン、キーボードの強押し、と三種類ものフリーカーソル移動ジェスチャが生まれてしまう。
一本指でのカーソル移動も実現しつつ、iPhone と iPad で操作を統一しつつ、標準ジェスチャの存在意義をスポイルしないデザインとは? ここでかなり悩むことになった。
二本指によるカーソル移動のメリットは、いきなり全方向にパンを開始できることだった。これを逆に考えると、一本指のパンは、スクロール操作とコンフリクトさせないために、水平方向で開始されなければならない。だから、一本指のパン=横方向のみのカーソル移動、と制限してしまうことで、他ジェスチャとの役割的な区別やジェスチャ発動の確実さをかろうじて担保できると考えた。
Textwell にはもともと、フリーカーソル移動を水平方向のみに限定するオプションがあった。これは、カーソルを全方向に動かせてしまうと、人によっては、ちょっと隣の文字にカーソルを移動するといった矢印キー的な操作がしにくいことが分かっていたからだった。つまり、全方向ではなく横方向のみにカーソルを動かすためのジェスチャとして、一本指によるパンというものをあてはめればよい。
そういう論理によって、v1.5 では、一本指のパンは横方向限定のカーソル移動となった。当然これは iPhone と iPad の両方で使える。iPhone と iPad の両方で、一本指は横方向限定、二本指は全方向のカーソル移動、という統一的なジェスチャを提供しようというわけだ。以前の Textwell で一本指による全方向カーソル移動を好ん使っていた人にとっては、今回、一本指では横方向にしかカーソルを動かせなくなったので、不便に感じるかもしれない。そこは、局所的な機能よりもシステム全体のインテグリティを優先する Textwell のデザインポリシーとして、どうかご容赦いただきたい。
一本指による横方向カーソル移動は、仮想カーソルの表現も含めて、二本指カーソル移動とだいたい同じプログラムで実装できる(Y軸の動きを無視するだけ)。しかしそれだけだと、カーソルは上の行や下の行に移動できなくて不便なので、キーボードの矢印キーがそうであるように、カーソルが行の末尾より右に行ったら下の行の先頭に、行の先頭より左に行ったら上の行の末尾に移動するようにした。この処理が意外と面倒だった。
仮想カーソルポイントは指がパンしはじめた座標を基準に指の動きに追従して移動するが、行の末尾から次の行の先頭にカーソルを移動するということは、その位置関係の分、仮想カーソルポイントも移動させなければならない。そして強制的に移動した仮想カーソルポイントは、指との位置関係を瞬時にリセットしつつ指のストロークをスムーズに反映し続けなければならない。この時に問題になるのが、空行をカーソルが通過する場合だ。単純に指の横方向への移動を仮想カーソルポイントがそのまま反映すると、カーソルは空行を一瞬で通過して次の行に行ってしまって、空行にカーソルを合わせることができない。同様に、行の折り返し地点や行の先頭などにもカーソルを合わせづらい。この問題を解決するために、仮想カーソルポイントが強制的に上や下の行に移動する際、仮想カーソルポイントと実際のカーソルの距離が30ポイントほど離れるまでは元の行にとどまろうとする「遊び」を実装した。これで、行をまたぐ時に少しの引っかかりが生まれ、空行や行の先頭/末尾にもカーソルを合わせやすくなった。
スクラブジェスチャによる全消去
残された最後の課題は、テキスト全消去のジェスチャだ。前バージョンまでは二本指の横スワイプだったが、当然このジェスチャはカーソル移動に取られたので使えないから、別の方法を考える必要がある。
消去のジェスチャは、例えばアンドゥのシェイクジェスチャがそうであるように、間接的なコマンド式のジェスチャだった。カーソル移動については直接性を重視しているのだから、消去についても、何かテキストを消すという行為と直接対応するような身体動作を用いたい。
例えば、マイクにふーっと息を吹きかけると全消去になるというアイデアもあった。これは真剣に実装を考えたが、息が吹きかけられたことを判定するための周波数は、日常の周辺ノイズとの切り分けが難しく、一定以上の誤判定が起こる。そのためテキスト全消去というリスクが高めなコマンドには適当でないと判断した。
そこで思い出したのが、Newton におけるスクラブジェスチャだった。Newton の Notes アプリでは、ペンで文字や図形の上にジグザグを描くと、それらが消去される。そして文字や図形が消えるとき、ボワっと煙が出るアニメーションが現れる。これは現実世界で書き損じの文字などの上をペンでぐじゃぐじゃと塗りつぶす動作とマッチしていて一度覚えると直感的だし、何より楽しい。これを真似しようと考えた。
そもそも Textwell(およびその前身の DraftPad)は、Newton の Notes に大きく影響を受けている。一枚の紙の上に自由に文字を書き、それを様々なアクションで他の機能にルーティングするというコンセプトは Newton から来ている。
スクラブジェスチャは、カーソル移動と同様に、一本指でも二本指でもでき、一本指の場合は水平方向、二本指の場合は水平または垂直方向のジグザグ動作で発動する(説明の便宜上ジグザグと言っているが、実際には、同じ軌道上を行ったり来たりした方が発動させやすい)ようにした。これで、カーソル移動ジェスチャとの一貫性も生まれるし、何より、片手操作で全消去できるようにもなる。
以前の二本指スワイプに比べるとやや難しいジェスチャにはなったが、誤発動しにくい、慣れた人向けのアドバンスドなショートカットという位置付けとして、許容範囲であると判断した。
長々と書いたが、Textwell v1.5 の新しいジェスチャ仕様は、このようにしてデザインされたのだった。