🎯

TextFieldの入力値をフォーマットするには

2022/12/13に公開

この記事はFOLIO Advent Calendar 2022の13日目の記事です。

FOLIOでは、現在toB向けアプリの開発をFlutterで行っています。
その開発のなかで、金額を扱うフォームに入力された値を、リアルタイムに3桁ごとのカンマ区切りにさせたいということがあり、TextInputFormatterを使用して実装したので、それがどのような実装になるかを解説したいと思います。

実装するフォーマッターの仕様

実装をシンプルにするために、↓の仕様でフォーマッターを作成します。

  • Flutterのバージョンは3.3.9
  • 入力できるのは数字のみ
    • 整数のみ受け付ける(今回は小数点以下を考慮しない)
    • 最大値はint.parseができる上限まで
  • 3桁ごとにカンマで区切りをリアルタイムに追加する
  • カンマの位置で削除した場合は、カンマの左隣の数字が削除される
  • 3桁ごとのカンマ区切りの処理はintlパッケージのNumberFormatを使用する
  • ターゲットの環境はiOS, Android

TextInputFormatter

TextInputFormatterを使用するには、TextFieldのinputFormattersに渡します。
たとえば、数字のみを入力したい場合は、↓のようにします。

TextField(
  keyboardType: TextInputType.number,
  textAlign: TextAlign.end,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
  ],
),

今回は、このFilteringTextInputFormatter.digitsOnlyの部分を、金額をカンマ区切りさせるためのTextInputFormatterに変更します。

動作

まずは、TextInputFormatterがどのような動作をしているのか確認します。

TextInputFormatterには、formatEditUpdateというメソッドが用意されており、ここにフォーマットするためのロジックを実装していきます。

TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue)

このメソッドの引数のoldValuenewValueはそれぞれ変更前と変更後の文字列とカーソルの位置(TextSelection)を含んだTextEditingValueです。

まずは、何も入力されていない状態から'1'を入力した場合に、oldValuenewValueにどんな値が入っているのかを見てみます。

oldValue = TextEditingValue(text: ┤├, selection: TextSelection.collapsed(offset: 0, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))
newValue = TextEditingValue(text: ┤1├, selection: TextSelection.collapsed(offset: 1, affinity: TextAffinity.downstream, isDirectional: false), composing: TextRange(start: -1, end: -1))

'1'を入力したタイミングで、formatEditUpdateが呼ばれ、textはoldValueが空、newValueは'1'になっています。
selectionは、oldValueが0、newValueが1になっていて、カーソルは右端にあるので、左端から0,1,2...と増えていくことがわかります。

実装

追加入力した場合のフォーマット

まずは、単純に受け取った値を3桁ごとの区切りにしていきます。

class NumberWithCommaTextFormatter extends TextInputFormatter {

    final _numberFormatter = NumberFormat('#,###');

    
    TextEditingValue formatEditUpdate(
        TextEditingValue oldValue,
	TextEditingValue newValue,
    ) {
        return TextEditingValue(
          text: _numberFormatter.format(
            int.parse(newValue.text),
        ),
      );
    }
}

これでとりあえず、3桁ごとにカンマ区切り自体は行えます。
(入力するとカーソルが常に先頭に移動してしまいますが、これはこのあと修正していきます🙏)

ですが、数字以外の文字が入ってきた場合は、parseすることができず、エラーになります。
そのため、まずはカンマと数字以外が入っていた場合、oldValueを返して入力を無かったことにする処理を追加します。
また、空文字の場合も処理しなくて良いのでnewValueをそのまま返します。

final _exceptNumberAndComma = RegExp(r'[^0-9,]+');
final _numberFormatter = NumberFormat('#,###');


TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
) {
    if (newValue.text.isEmpty) {
      return newValue;
    }
    
    if (_exceptNumberAndComma.hasMatch(newValue.text)) {
      return oldValue;
    }
    
    return TextEditingValue(
      text: _numberFormatter.format(
        int.parse(newValue.text),
      ),
    );
}

これで、newValue.textが数字またはカンマだけであることが担保されたので、あとはintにparseするためにカンマを取り除きます。

+ int.parse(newValue.text);
- int.parse(newValue.text.replaceAll(',', ''));

さらにintの上限を超える場合、パースに失敗するので、try-catchで囲って、失敗した場合はoldValueを返すようにします。

final int value;
try {
  value = int.parse(newValue.text.replaceAll(',', ''));
} on FormatException {
  return oldValue;
}


return TextEditingValue(
  text: _numberFormatter.format(
    value,
  ),
);

これで、intの上限までであればエラーが発生することなく入力することができます。

ここまでで、追加入力に関しては仕様を満たす動きにすることができたと思います。

削除した場合のフォーマッター

今まで入力された場合のみ考えていたので、次は削除された場合についても考えていきます。

削除する場合で特に考える必要のあることは、

  1. 100,123などで一番左の「1」を削除して、123となる場合
  2. カンマの右隣の位置で削除した場合
    の2点です。

まずは、簡単な1.の方から対応していきます。
現在の実装のままで、これを実行すると、Selectionの位置が0未満の値になってしまうためエラーになります。

ex. 「1,001」で右端の「1」を削除した場合

newValue.text = ,001;
formattedText = 1;

newValue.selection = 0;
newValue.text.length = 4;
formattedValue.length = 1;

formattedSelection = 0 + 1 - 4 = -3;

そのため、先程計算したSelectionの位置が0未満の場合0にする処理を追加します。

selection: TextSelection.collapsed(offset: selection < 0 ? 0 : selection),

これで、Selectionが負の値になった場合Selectionが0になるためエラーになりません。

次に2.の対応をしていきます。
現在の実装では、カンマの位置で削除するとただカンマが削除されるだけなので、再度フォーマットをかけると、消されたカンマが追加されるだけなので、見た目上は変化がありません。
ですが、今回はカンマの右隣で削除された場合、その左隣の数値を消す仕様にしたいため、カンマが削除されたかどうかを判定する必要があります。

まずは、削除か追加かを判定する処理を追加します。
削除か追加かは、newValue.text.lengthとoldValue.text.lengthの差が、正の値であれば追加で、負の値であれば削除ということがわかります。
なので、その判定を追加します。

final isDeleted = newValue.text.length - oldValue.text.length < 0;

そして、削除の場合は、さらにカンマが削除されているかの判定をします。
削除された文字がカンマかどうかは、oldValue.textのnewValue.selection ~ oldValue.selectionの間の文字がカンマかどうかを見ることで判定できます。

ex.

oldValue.text = '1,234';
oldValue.selection = 2;
newValue.text = '1234';
newValue.selection = 1;

oldValue.text.substring(newValue.selection, oldValue.selection);

実装は↓のようになります。

if (idDeleted) {
    final isCommaDeleted = oldValue.text.substring(
        newValue.selection.extentOffset,
	oldValue.selection.extentOffset,
    ) == ',';
}

これで、カンマの左隣でカンマだけが削除されているかどうかは判断できたので、次はカンマの右側の文字を削除する処理を追加します。
カンマの右側の文字を削除する場合は、newValueTextのnewValue.selection - 1 ~ newValue.selectionの値を空文字に置き換えます。

if (isCommaDeleted) {
    final adjustedText = newValue.text.replaceRange(
        newValue.selection.extentOffset - 1,
	newValue.selection.extentOffset,
	'',
    );
}

これで削除時も仕様どおりに動作するようになりました。

追加入力した場合のSelection

次は、カーソル位置の計算を行うためにSelectionを設定していきます。

カーソル位置を考慮していない現在の実装では、入力したり削除したりしてフォーマットが走る度に、Selectionの位置が0番目に戻ってしまうため、常に先頭(この場合左端)にカーソルが移動してしまいます。
なので、入力しやすくするため、入力中でもカーソルの位置が変わらないようにします。

今回のようなカンマ区切りなどの文字を追加するようなフォーマッターではない場合、Selectionは1文字変更するごとに1ずつプラス、マイナスされていくだけなので、newValueのSelectionをそのまま使用すればよいです。
しかし、今回は入力した文字以外にカンマが追加される可能性があるため、、カンマが追加されたタイミングで、Selectionを+2(新しい入力された1文字 + カンマ)する可能性があります。
実際には、newValue.selectionに、フォーマットされたtextのlengthとnewValue.textのlengthの差を加算した値がselectionの値になります。
そのため、Selectionの位置調整には追加、削除関係なく同じ処理を使用して計算することができます。

ex. 123が入力されており末尾にカーソルがある状態で4を入力した場合

oldValue.text = '123';
newValue.text = '1234';
formattedValue = '1,234';

newValue.selection = 4;
newValue.text.length = 4;
formattedValue.length = 5;

formattedSelection = 4 + 5 - 4 = 5;

newValue.selectionはすでに1文字追加されたSelectionの位置なので、
それにカンマ分の1がプラスされた5がSelectionの位置になる。

ex. 1,234が入力されておりカンマを削除した場合

oldValue.text = '1,234'
newValue.text = '1234';
formattedValue = '234';

newValue.selection = 1;
newValue.text.length = 4;
formattedValue.length = 3;

formattedSelection = 1 + 3 - 4 = 0;

newValue.selectionはカンマの右隣の文字を削除する前のSelectionの位置なので、
カンマの右隣の文字を1文字削除した文の1がマイナスされた0がSelectionの位置になる。

実装は↓のようになります。

final formattedText = _numberFormatter.format(adjustedText);
final selection =
    newValue.selection.extentOffset + formattedText.length - newValue.text.length;

return newValue.copyWith(
  text: formattedText,
  selection: TextSelection.collapsed(offset: selection),
);

extentOffsetは、文字列が範囲選択されていた場合の終了位置です。
なので、例えば「123」が入力されている状態で、「2」を選択して「4」を入力した場合、「2」の位置に「4」が入力され、カーソルが4の右隣になる必要があるため、範囲選択の終了位置を使用します。

最終的に出来上がるTextInputFormatterは↓です。

https://gist.github.com/m-kikuchi777/af7ba8eee3e340a5776e243a82cb394f

初期値のフォーマット

カンマ区切りするTextInputFormatterが完成したので、これをTextFieldに設定すると、入力中はリアルタイムにフォーマットされるのが確認できると思います。
ですが、TextFieldの初期値にカンマ区切りされていない文字を設定すると、initialValueはフォーマットされません。
例えば、textに100000という値をセットしたTextEditingControllerを追加し、TextFieldに渡すとすると、描画された際に、フォーマットされた100,000という値が表示されることを期待すると思いますが、実際には100000がそのままセットされます。
なので、初期値もフォーマットされるようにする方法を考えます。

なぜ初期値は自動でフォーマットされないのか

まず、なぜ初期値が自動でフォーマットされないのかを、TextField内のコードを見ながら考えていこうと思います!

まずは、入力時にTextInputFormatterが呼ばれる場所を確認します。
TextFieldは、EditableTextを内部で使用していて、そのEditableTextにTextFieldに渡したTextInputFormatterをそのまま渡しています。
EditableText内では、EditableTextState内の、_formatAndSetValue内で、TextInputFormatterを使用しています。

https://github.com/flutter/flutter/blob/3.3.9/packages/flutter/lib/src/widgets/editable_text.dart#L2733-L2760

この_formatAndSetValueを呼んでいる箇所は、userUpdateTextEditingValue内とupdateEditingValue内の2箇所から呼ばれています。
ですが、このどちらも入力値が変化した場合、もしくはSelectionが変わった場合に呼ばれます。

そして、_formatAndSetValue内では、textChangedフラグを持っていて、これがtrueの場合にフォーマットが呼ばれます。
textChangedフラグは、_value(変化前のTextEditingValue = oldValue)と, value(変化後のTextEditingValue = newValue)のtextを比較し、変わっていればtrueになります。
もしくは、_valuevaluecomposing.isCollapsedの値で変わっているかを見ていますが、これは日本語入力などで未確定状態から確定状態にした際や、単語の予測変換を適用した場合に変わる部分なのですが、今回は日本語入力をさせていないので、関係ない部分です。
参考: composing property

なので、初回の値がセットされたあとの状態からフォーマットを行うかの処理が呼ばれるため、初期値をセットしたあとのフォーマットは自動で呼ばれないことになります。

手動でのフォーマット

自動でフォーマットがされないことがわかったので、できる方法としては初回の値をフォーマットして渡すというのが最も単純な解決方法になると思います。
なので、今回実装したNumberTextInputFormatterをセットする際に、formatEditUpdateを呼んで、帰ってきた値をinitialValueにセットする方法をとってみようと思います。

@override
void initState() {
  super.initState();
  _textController = TextEditingController.fromValue(
    NumberTextInputFormatter().formatEditUpdate(
      TextEditingValue.empty,
      const TextEditingValue(text: '10000000'),
    ),
  );
}

oldValueにTextEditingValue.emptyを渡して、newValueに初期値としてセットしたいtextを持ったTextEdigingValueを渡し、その返り値からTextEditingControllerを作成することで、NumberTextInputFormatterと同じロジックを使用して初期値をフォーマットすることができます。

まとめ

最終的に出来上がるものは↓の内容になります。
https://dartpad.dev/?id=1ddd39885e1ff8d9c908f77e8bab3986

Flutterで入力値をリアルタイムに整形する場合は、TextInputFormatterを使用すれば、
今回はなるべくシンプルになるように、intがparseできるところまでで実装しましたが、その部分を変えればより大きな値を扱えるようになりますし、少数を扱えるようにするなど色々と仕様を変えることはできます。

その他独自のTextInputFormatterを実装することで、入力中にリアルタイムでフォーマッタをかけることができるようになるので、色々実装を試して見てください!

Discussion