iOS で文字数制限つきのテキストフィールドをちゃんと作るのは難しいという話

「そんなん簡単やろ」と思いますよね。

たとえば、「UITextField 文字数制限」でググれば山のようにブログ記事やらコードが出てくるし、Stack Overflow に載ってるコードのコピペ一発で解決しそうに思えませんか?

実は文字数制限をつけたテキストフィールドはそんなに簡単な話ではないのです。

shouldChangeCharactersInRange:replacementString: は使えない子

今回はこれに尽きます。

UITextFieldUITextView のデリゲートで呼ばれる textField:shouldChangeCharactersInRange:replacementString:textView:shouldChangeCharactersInRange:replacementString:使ってはいけません。 より正確に言うと、使うときはとても気をつけて使わないといけません。

実は Apple の API には仕様に欠陥があり、まともに使えないものが結構潜んでいます。そして shouldChangeCharactersInRange:replacementString: デリゲートはその代表例になります。

このデリゲートはテキストフィールドに iOS の入力システムから入力が行われる直前に呼ばれて BOOL 値を返し、その入力を許可するかしないかを判定するのに利用します。

例えば、先のキーワードでググると出てくる次のようなコードを例に考えましょう。

- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string
{
    return [[textField.text stringByReplacingCharactersInRange:range withString:string] length] <= MAX_LENGTH;
}

この例では、なにか文字が入力される直前に呼ばれて、入力した結果のテキストを stringByReplacingCharactersInRange:withString: で作って、その長さが MAX_LENGTH を越えない限りその入力を許可する、という一見なんの問題もないように見えるコードになります。

確かに、英語キーボードしか使わないのであればこれでほとんど問題にはならないでしょう。 しかし、実際にはこの実装はとんでもない動作をすることになります。

日本語とか中国語の問題

日本語についてはみなさん詳しいでしょう。中国語も基本同じなのですが、これらの言語の入力には複数の状態があります。

まず、入力を開始するとキーボードから文字や形状変化、たとえば濁点や小文字などが入力されるたびにマーク状態として文字が追加あるいは更新されます。

しろくろのがいる しろくろのこねこにやがいる しろくろのこねこにゃがいる しろくろのこねこにゃーがいる

つぎに、日本語の場合は変換操作を行い、マーク状態の文字の一部が変換した文字に置き換わり、確定されます。

しろくろのねこにゃーがいる しろくろの子ねこにゃーがいる しろくろの子🐱ニャーがいる

この流れは中国語の様々なキーボードでも同様で、キーボードからの入力が一旦未確定のマーク状態を経て、確定、入力されるという状態遷移になります。

この流れの中で shouldChangeCharactersInRange:replacementString: はどのような動きをするでしょうか?

実はこの shouldChangeCharactersInRange:replacementString: はいかなる状態においてもキーボードから何らかの入力があるたびに呼ばれます。それはマークされた文字を入力している、あるいは変換過程でも呼ばれます。そこで、それぞれの表示状態でどのようにこのデリゲートが呼ばれるか表にしてみました。

入力後の表示状態 shouldChangeCharactersInRange: replacementString: markedRange
しろくろのがいる 初期状態 nil
しろくろのこねこにやがいる location = 5, length = 0 こねこにや nil
しろくろのこねこにゃがいる location = 10, length = 0 start = 5, end = 9
しろくろのこねこにゃーがいる location = 10, length = 0 start = 5, end = 9
しろくろのねこにゃーがいる カーソル移動のため呼ばれない
しろくろの子ねこにゃーがいる location = 5, length = 1 start = 5, end = 10
しろくろの子🐱ニャー</span>がいる location = 6, length = 5 🐱ニャー start = 6, end = 10

さてこの表を見て愕然とすることがあると思います。 最も驚くのが「こねこにや」を「こねこにゃ」と小文字にする際にやってくるreplacementString: です1。 意味がわかりません。なにこの黒いニコニコマーク… クパチーノで流行ってる冗談かなにかでしょうか? 本当に悪い冗談です。

自然に考えれば、shouldChangeCharactersInRange: が最後の「や」の範囲を示し、replacementString: は「ゃ」だと思うのでが、そのような実装になっていません。これは「ほ」を「ぽ」や「ぼ」に変える際にも呼ばれます。

推測ですが、日本語のかなキーボードにある「小 ゛ ゜」キーは通常状態だと「^_^」の顔文字キーになっています。 これは有名な話ですが、iOS では をヨミに顔文字を辞書登録するとこの「^_^」顔文字キーで入力できるようになっていますが、このキーは内部的には を入力する、という実装になっているのでしょう。

この挙動があるために、まず [[textField.text stringByReplacingCharactersInRange:range withString:string] length] <= MAX_LENGTH; のような判定はできないことがわかります。 なぜなら、もしこのテキストフィールドが10文字が最大長だったとしたら、「や」を「ゃ」にする際に、rangestring で置き換えたら「しろくろのこねこにや☻」になって11文字となり文字数オーバー、デリゲートが NO を返し「や」を「ゃ」にできなくなるからです。実際は「や」が「ゃ」になるだけなので10文字のままなのですが。

このように、 shouldChangeCharactersInRange:replacementString:replacementString: はまったくアテにならないのです。そして NO を返してしまうとキーボードの正常な動作を阻害してしまうのです。

これだけでもだいぶヒドイのですが、問題はこれだけではなく。

チェコ語とか韓国語とかの問題

日本語や中国語ではまずキーボードからの文字はマーク状態を経て確定されるという状態遷移でした。 しかし、チェコ語や韓国語など文字の部品を組み合わせて入力する言語ではまた異なった状態遷移をします。

たとえばチェコ語などのアルファベット「Č」2の入力と、韓国語ハングルの「가」や「간」3の入力を見てみましょう。

入力後の表示状態 shouldChangeCharactersInRange: replacementString: markedRange
AB 初期状態 nil
ABC location = 2, length = 0 C nil
ABČ location = 3, length = 0 ˇ nil
入力後の表示状態 shouldChangeCharactersInRange: replacementString: markedRange
初期状態 nil
location = 1, length = 0 nil
오가 location = 1, length = 0 nil
오간 location = 2, length = 0 nil

これらの言語ではまず文字が確定されていないというマーク状態が存在しませんので、markedRange は常に nil を返します。 アルファベット「C」は「C」として入力された時点で確定された文字です。

ハングルの場合はさらに複雑で、ある文字が入力されてからそのあとに入力される文字によって、その入力した文字が前の文字の一部になるかならないかが逐次決まります。 「오간」を得る過程では「ㅇ」「오」「옥」「오가」「오간」のように文字が逐次変化します。 そしてどの状態であっても文字としては入力した時点で文字として確定されています。

この流れの中で shouldChangeCharactersInRange:replacementString: の動きを見てみると日本語の小文字と同じような問題を抱えていることがわかります。

例えば、ダイアクリティカルマークの「ˇ」を入力すると、replacementString: として ˇ を「C」のあとに入力するという呼び出しとなり、「C」を「Č」に置き換えるという実装にはなっていません。 ハングルの場合も同様で常に入力されたそれぞれの文字が追加されてるという実装になっています。

この結果、日本語の小文字と同様の問題が起こることがわかります。 特にハングルの場合、文字構成の途中での文字削除が有効ですので、逐次入力によって最終的に文字が確定するまでに何文字の入力があるのか最後まで確定しません

根本的な問題

このように shouldChangeCharactersInRange:replacementString: は本当に使えない子なのですが、根本的な問題は、現状 iOS の公開された API では、ある入力がテキストフィールドに行われるよりも前に、その入力した結果を実際に入力しないで知る方法が存在しないということです。

例えば、「や☻」が「ゃ」になるとか、「ㅇㅗㄱㅏㄴ」が「오간」になるということが、実際のテキストフィールドに文字を入力しない限りわかりません。その結果、文字数制限や入力文字の制限など、入力に制限を課すことが素直にはできないのです。

しかし、ここでめげてはいけません。なんとかしましょう。

代替手段

まず、すべての入力が行われた直後に発行される UITextFieldTextDidChangeNotificationUITextViewTextDidChangeNotification 通知や、UITextField の場合、UIControlEventEditingChanged イベントがあります。

これらは shouldChangeCharactersInRange:replacementString:YES を返した直後にテキストフィールドの状態が確定してから呼ばれますので、つまり、このタイミングで確定したテキストフィールドの状態を適切な状態に戻せば良いということになります。しかし、確定した文字だけでは戻すべき正しい状態はわかりません。たとえば文字数制限の場合直前の入力がどこに行われたかなどが必要になります。

先ほどの日本語の例文が 10 文字制限のテキストフィールドに入力されるとしましょう。

入力後の表示状態 [text length] 実際の文字数 状態
しろくろのがいる 8 8 入力開始
しろくろのこねこにやがいる 13 8 マークテキストを入力
しろくろのこねこにゃがいる 13 8 表示上は10文字以上だがまだ確定されていない
しろくろのこねこにゃーがいる 14 8 さらにマークテキストを入力
しろくろのねこにゃーがいる 14 8 カーソル移動しても変わらない
しろくろの子ねこにゃーがいる 14 9 あと1文字のみ入力可能
しろくろの子🐱ニャー</span>がいる 13 13 文字数オーバーなのでこの状態はダメ
しろくろの子🐱</span>がいる 10 10 この状態になるのが望ましい

このように、文字数制限の場合は入力確定後に文字数制限を越えたことがわかった場合、直前の変更によって入力された文字の入力を制限して状態を戻す必要があります。 また、マークテキストを入力中は文字数制限を行ってはいけません。

というわけで正しく文字数制限を行うには次のような実装になります。

UITextField *_textField;
NSUInteger _maxLength;

NSString *_previousText;
NSRange _lastReplaceRange;
NSString *_lastReplacementString;

- (instancetype)init
{
    ...
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_textFieldDidChange:) name:UITextFieldTextDidChangeNotification object:_textField];
    _maxLength = 10;
    ...
}

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

- (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)string
{
    _previousText = textField.text;
    _lastReplaceRange = range;
    _lastReplacementString = string;

    return YES;
}

- (void)_textFieldDidChange:(NSNotification *)notification
{
    UITextField *textField = (UITextField *)notification.object;

    if (textField.markedTextRange) {
        return;
    }

    if ([textField.text length] > _maxLength) {
        NSInteger offset = _maxLength - [textField.text length];

        NSString *replacementString = [_lastReplacementString substringToIndex:([_lastReplacementString length] + offset)];
        NSString *text = [_previousText stringByReplacingCharactersInRange:_lastReplaceRange withString:replacementString];

        UITextPosition *position = [textField positionFromPosition:textField.selectedTextRange.start offset:offset];
        UITextRange *selectedTextRange = [textField textRangeFromPosition:position toPosition:position];

        textField.text = text;
        textField.selectedTextRange = selectedTextRange;
    }
}

ここでは shouldChangeCharactersInRange:replacementString: で直前の入力状態を保存して、UITextFieldTextDidChangeNotification 通知で状況を判断、直前の入力状態からあるべき状態を作り出しています。 前述のとおり shouldChangeCharactersInRange:replacementString: は意味不明な replacementString: を返すことがありますが、現状では、結果的に文字数が増えたというオペレーションにおいては replacementString: は常に正しく追加された文字列を示しているはずです。

UITextField の場合、選択位置を示す selectedRangeUITextInput プロトコルとなり、 UITextRange という抽象的な表現で行われますので多少面倒ですが positionFromPosition:offset: を使って期待する選択位置をつくります。

まとめ

このように単に文字数制限のあるテキストフィールドを実装するだけでも大変でしたが、似たような問題としてキーボードから入力がある時に逐次確定した文字を使うインクリメンタル検索の実装などがあります。そこで今回のまとめ。

shouldChangeCharactersInRange:replacementString: は使わないようにしましょう。使う場合でも以下の点をよーく考えて使いましょう。

  1. shouldChangeCharactersInRange:replacementString: は他の UITextFieldTextDidChangeNotification 通知や UIControlEventEditingChanged イベントで代用できないかもう一度考える

    特にインクリメンタル検索のような実装では必ずこれらの通知かイベントを使用しましょう。 インクリメンタル検索が日本語でまともに動かないバグのあるアプリ(Foursquare とか)はこの実装が残念なのだと思います。

  2. shouldChangeCharactersInRange:replacementString:常に YES を返すこと。

    NO を返せるタイミングはとても限られています。というか入力開始直後だけです。それ以外の状態では NO を返すといくつかの言語でキーボードそれ自体の動作が成立しなくなります。特に韓国語のハングルではほとんどの文字の入力が成立しなくなります。

  3. replacementString:アテにならない

    入力された文字とは限りません。「☻」だったりしますし、その「☻」にしても将来違う文字に変わるかもしれません4

補足

そもそも文字数制限のあるテキストフィールドがおかしいんだ! っていうコメント頂きました、それはまあちょっとこの記事とは論点が違うんですが、その通りだと思います。

そのへんはアプリのデザインに影響があることだと思いますので個々に判断していただくとして、まとめでも書いたとおり shouldChangeCharactersInRange:replacementString: の使用には気をつけてね、ということが言いたいことなのでした。 (7/26/2014 補足)

  1. なお、この は現時点でのベータ版 iOS 8 では に変わっています。 ↩︎

  2. Cにダイアクリティカルマークのハーチェク ˇ ↩︎

  3. 母音 a の に子音 gの 、そして終音 n の  ↩︎

  4. 事実、この は iOS 8 で変わるようです。 ↩︎