2012年8月31日金曜日

UITextFieldのキャレットを操作(UITextRangeとUITextPosition)

まず、「キャレット」と「選択範囲」という言葉について。

  キャレ|ットは3文字目の位置にある

こういう文字入力時のカーソルのことを「キャレット」と呼びます。

  2文字目から6文字目が選択されている

複数の文字を選択している場合、キャレットは非表示となり「選択範囲」がハイライト表示されます。

iOSのテキストシステムの内部ではキャレットと選択範囲のハイライトとを、同一の仕組みで扱っているため、以下で「キャレット」と呼んだ場合、「選択範囲」も含むものだと考えてください。

UITextFieldのキャレットの位置を取得するためには、selectedTextRangeプロパティを参照します。このプロパティはUITextRangeクラスのオブジェクトです。

----

Objective-Cには、NSRangeという範囲を保持する構造体があります。NSStringを操作するときに使わされてお世話になってることだと思います。

なぜUITextFieldではUITextRangeをわざわざ使うのでしょうか?文字列の範囲を保持するために、構造体ではなくオブジェクトを使うのは一見無駄に思えます。

その理由は二つです。
  • HTMLのようなネストしたドキュメントにおいて、メタテキストと可視テキストの両方の追跡が求められるため。
  • iPhoneのテキストシステムの基となったWebKitフレームワークで、テキスト始点や終点をオブジェクトで表す必要があった。
後者は完全にAppleの都合ですが、前者については理はあります。例えばHTML文書の「強調」という文字を選択しているとき、可視テキストを見れば「"強調"の2文字選択している」という解釈になりますが、HTMLとしては「"<b>強調</b>"の9文字選択している」のです。

NSRangeのような単純な型では、このようなメタ情報までは保持できないのです。

----

UITextRangeは3つのプロパティを持ちます。

まずstartとendプロパティ、二つのUITextPositionがあります。これらは選択の始点(start)と、終点(end)を保持するものです。

始点と終点の位置が同じならば、選択ではなくキャレットの状態ということになります。これを調べるためのemptyプロパティが存在します。BOOL型なのでisEmptyで参照できます。

UITextRangeは不変オブジェクトであり、startやendの位置を操作できません。UITextPositionもまた、一切のメソッドを宣言しない(アクセサも当然ないから公開プロパティを持たない)クラスであり、特定の位置を示すUITextPositionを生成することもできません。

それはそのはずで、UITextPositionの実体は対象がプレーンテキストか、或いはHTML文書かによって指し示すものが変わるものであり、UITextPositionやUITextRangeは文書の意味構造に関わらず、共通の文書操作のインターフェースを提供するための抽象クラスだからです。

このことは、もし何らかの操作可能な文書を保持するクラスを作るときは、その文書の意味構造に応じて、UITextRangeとUITextPositionをオーバーライドする必要があることを意味します。

----

以上から、UITextFieldのキャレット(すなわちUITextRange)を操作するためには、UITextField自身のメソッドを利用する必要があることが分かります。

UITextRangeやUITextPositionの操作に必要なメソッド群は、UITextInputプロトコルで宣言されているものです。関連したものを以下に列挙します。

プロパティ
  • beginningOfDocument
    • ドキュメントの先頭のUITextPositionを保持
  • endOfDocument
    • ドキュメントの終端のUITextPositionを保持
  • selectedTextRange
    • 現在選択されているテキスト範囲をUITextRangeで保持
メソッド
  • offsetFromPosition:toPosition:
    • 指定した2つのUITextPositionの距離をNSIntegerで取得。
  • positionFromPosition:offset:
    • 指定したUITextPositionから第二引数だけ離れた新しいUITextPositionを取得。
  • positionWithRange:atCharacterOffset:
    • 選択範囲の始点からCharacterOffsetだけ離れたUITextPositionを取得?
  • textInRange:
    • UITextRangeから対応する文字列を取得
  • textRangeFromPosition:toPosition:
    • 指定した2つのUITextPositionを範囲とするUITextRangeを取得
これで全てではありません。その他については、UITextInputのプロトコルリファレンスを参照してください。

----

以下は「UITextFieldにキャレット移動メソッドを追加させてみた」という実例です。

/*
 キャレットをドキュメント始点方向に移動させます
 */
-(void)caretMoveToBackward
{
        UITextRange *currentRange = self.selectedTextRange;
        if([currentRange.start isEqual:self.beginningOfDocument]){
                return;
        }

        UITextPosition *newPosition = 
              [self positionFromPosition:currentRange.start offset:-1];

        UITextRange *newRange;
        if([currentRange isEmpty]){
                newRange = [self textRangeFromPosition:newPosition
                                            toPosition:newPosition];
        }else{
                newRange = [self textRangeFromPosition:newPosition
                                            toPosition:currentRange.end];
        }

        self.selectedTextRange = newRange;
}

見ての通り、死ぬほど面倒です。

まず既に始点ならばガード節で弾きます。UITextFieldに不正なUITextRangeを与えると、問答無用でアプリが落ちてしまうからです。

次に選択範囲の始点から-1の距離を指定した新しいUITextPositionを取得します。

そして現在のNSRangeが、キャレットか範囲選択かに応じて場合分けをします。

ただここで考えなければならないのは、範囲選択時に「キャレットを前へ動かす」というボタンに対して、ユーザーがどういう挙動を期待しているのか、ということです。考えられるものを列挙してみると、
  1. 選択範囲全体が動くべき
  2. 始点だけが動いて選択範囲が広がるべき
  3. 選択を解除してキャレットに戻した上で一文字移動するべき
  4. 選択を解除してキャレットに戻すだけで移動はするべきではない
Windows PCでカーソルキーを押したときの挙動は、意外にも4.なのです。

もっともiOSは範囲を選択しやすいマウスのあるWinPCとは違います。ここでは指で細かい範囲指定を要求されて、発狂する人を助けるための機能として作ったので、2.で実装しました。

----

「使いやすさ」という言葉に対しては様々な議論がありますが、個人的には「ユーザーが期待した動作を実現する」ことが重要だと考えています。

ユーザーの期待の実現、すなわちメンタルモデルに沿わせるための簡単な方法は、標準にあわせることです。

Appleがガイドラインを公開して、それに沿うよう強制するのも、アプリ毎に個々のUIの意味や挙動が変わり、悪い意味で期待を裏切り、思わぬ結果に驚かせるような、ユーザー体験を損ねる挙動を出来るだけ排除するという狙いがあるのでしょう。

なのでそれをする必然な理由が存在しないならば、独自の挙動は出来るだけ回避するべきだと思います。

----

しかしこのAPIはないわー感満載。せめてNSRangeからUITextRangeに変換するメソッドくらいあってもいいのに…。

----

余談。

テキストフィールドのbecomeFirstResponderをコールすると次の流れで処理されます。

  1. UIResponderのbecomeFirstResponderを呼ぶ。
  2. UITextFieldの_becomeFirstResponderが呼び出される。
  3. UIControlのUIControlEventEditingDidBeginイベントにバインドされたメソッドが呼び出される。
  4. UITextFieldのtextfieldDidBeginEditingが呼び出される。
UITextRangeを操作できるのは、textfieldDidBeginEditing以降です。

UIControlのUIControlEventEditingDidBeginイベントにキャレットを操作するメソッドを実行させることはできません。より厳密に言えば、変更がなかったことにされて、endOfDocumentの位置にキャレットが移動されています。

なぜこういう仕様なのかは謎ですが、システムで予約されたプライベートメソッド内でそういう処理をしているのでどうしようもないです。迂闊にいじろうものならリジェクト確実なので疑問を持ってはいけません。

もし「既に入力済みのフィールドを選択したら、選択済み状態にして欲しい」などの機能を実装するときは、デリゲートメソッド以降でキャレットを操作しましょう。

0 件のコメント:

コメントを投稿