【Flutter】TextInputFormatterを使って数値をフォーマット

  • 2024年11月28日
  • 2024年11月28日
  • Widget

対象者

  • Flutterでカスタム入力フォーマッターを実装したい方
  • 数値入力フィールドにカンマ区切りを追加したい方
  • TextInputFormatterの使い方を理解したい方

はじめに

FlutterでTextFieldにデータを入力する際、ユーザーが入力した金額や数値をリアルタイムでカンマ区切りやスラッシュ、空白を表示したい場合があります。そのようなときのために、本記事では、数字の3桁区切りを例にして、TextInputFormatterをカスタマイズし、入力中に自動でカンマ区切りを追加する方法を解説します。

カスタムTextInputFormatterの実装

予備知識

このソースを理解するにはTextEditingValueを知っている必要がある(多分)ので、深く理解したい場合はお読みください。手っ取り早く改造するためのソースが欲しいだけなら、不要です(笑、やりがち)

CurrencyFormatterの作成

まず、CurrencyFormatterというカスタムクラスを作成します。このクラスはTextInputFormatterを継承し、formatEditUpdateメソッドをオーバーライドします。

class CurrencyFormatter extends TextInputFormatter {
  const CurrencyFormatter();

  @override
  TextEditingValue formatEditUpdate(
      TextEditingValue oldValue, TextEditingValue newValue) {
    final newFormattedText = _format(newValue.text);
    final calculatedTextSelection = _calculateSelectionOffset(
      oldValue: oldValue,
      newValue: newValue,
      newText: newFormattedText,
    );

    return newValue.copyWith(
      text: newFormattedText,
      selection: calculatedTextSelection,
    );
  }

  String _format(String input) {
    if (input.isEmpty) {
      return input;
    }
    final value = input.replaceAll(',', '');
    return value.toString().split('').reversed.reduce((result, e) {
      return e + (result.length % 4 == 3 ? ',' : '') + result;
    });
  }

  TextSelection? _calculateSelectionOffset({
    required TextEditingValue oldValue,
    required TextEditingValue newValue,
    required String newText,
  }) {
    final oldOffset = oldValue.selection.baseOffset;
    final newOffset = newValue.selection.baseOffset;

    // オフセットの場所が文字数より多い場所にある場合
    // → 文末の文字を削除したとき
    if (newText.length < newOffset || newText.length < oldOffset) {
      return TextSelection.collapsed(offset: newText.length);
    }

    // 古いオフセットが古いテキストの長さに等しい場合、新しいテキストの末尾にオフセットを移動します。
    // → 文末に文字を追加したとき
    if (oldValue.text.length == oldOffset) {
      return TextSelection.collapsed(offset: newText.length);
    }

    final differenceNumberOfBreakCharacter =
        ','.allMatches(newText).length - ','.allMatches(oldValue.text).length;

    // 分割文字の差に基づいてオフセットを増やすことで調整する
    // → カーソルの後ろに分割文字があるときに、分割文字が増えたとき
    if (differenceNumberOfBreakCharacter == 1 &&
        newText[newOffset + 1] == ',') {
      return TextSelection.collapsed(offset: newOffset + 1);
    }

    return null;
  }
}

TextFieldへの適用

作成したCurrencyFormatterTextFieldinputFormattersに追加します。

TextField(
    maxLines: 1,
    maxLength: 30 + 8,
    inputFormatters: const [CurrencyFormatter()],
    decoration: InputDecoration(
      labelText: '金額',
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12.0),
      ),
    ),
),

動作の解説

リアルタイムのカンマ区切り

_formatメソッドでは、入力されたテキストを反転させてから3桁ごとにカンマを挿入しています。

String _format(String input) {
    if (input.isEmpty) {
      return input;
    }
    final value = input.replaceAll(',', '');
    return value.toString().split('').reversed.reduce((result, e) {
      return e + (result.length % 4 == 3 ? ',' : '') + result;
    });
}

カーソル位置の計算ロジック

カンマが挿入・削除されることでカーソル位置がずれる問題を、_calculateSelectionOffsetメソッドで解決しています。このメソッドは、テキストの変更によって生じるカーソル位置のズレを(なるべく)正確に計算し、ユーザーが直感的に入力できるようにします。
とはいえ、「,」の右で削除した場合は、カーソル移動するだけとか、改善の余地があることは分かっている、、

TextSelection? _calculateSelectionOffset({
    required TextEditingValue oldValue,
    required TextEditingValue newValue,
    required String newText,
  }) {
    final oldOffset = oldValue.selection.baseOffset;
    final newOffset = newValue.selection.baseOffset;

    // オフセットの場所が文字数より多い場所にある場合
    // → 文末の文字を削除したとき
    if (newText.length < newOffset || newText.length < oldOffset) {
      return TextSelection.collapsed(offset: newText.length);
    }

    // 古いオフセットが古いテキストの長さに等しい場合、新しいテキストの末尾にオフセットを移動します。
    // → 文末に文字を追加したとき
    if (oldValue.text.length == oldOffset) {
      return TextSelection.collapsed(offset: newText.length);
    }

    final differenceNumberOfBreakCharacter =
        ','.allMatches(newText).length - ','.allMatches(oldValue.text).length;

    // 分割文字の差に基づいてオフセットを増やすことで調整する
    // → カーソルの後ろに分割文字があるときに、分割文字が増えたとき
    if (differenceNumberOfBreakCharacter == 1 &&
        newText[newOffset + 1] == ',') {
      return TextSelection.collapsed(offset: newOffset + 1);
    }

    return null;
}
  1. オフセットの取得:

    final oldOffset = oldValue.selection.baseOffset;
    final newOffset = newValue.selection.baseOffset;
    
    • oldOffset: 変更前のカーソル位置。
    • newOffset: 変更後のカーソル位置。
  2. テキスト長によるカーソル位置の調整:

    if (newText.length < newOffset || newText.length < oldOffset) {
      return TextSelection.collapsed(offset: newText.length);
    }
    
    • 新しいテキストが古いカーソル位置より短い場合、削除操作が行われたと判断し、カーソルをテキストの末尾に移動します。
  3. テキスト末尾での入力処理:

    if (oldValue.text.length == oldOffset) {
      return TextSelection.collapsed(offset: newText.length);
    }
    
    • 旧テキストの末尾で入力があった場合、カーソルを新しいテキストの末尾に移動します。
  4. カンマの増減によるカーソル位置の調整:

    final differenceNumberOfCommas =
        ','.allMatches(newText).length - ','.allMatches(oldValue.text).length;
    
    if (differenceNumberOfCommas == 1 && newText[newOffset + 1] == ',') {
      return TextSelection.collapsed(offset: newOffset + 1);
    }
    
    • カンマの数の差を計算し、新しいテキストでカンマが1つ増えた場合に特別な処理を行います。
    • カーソルの右隣にカンマが追加された場合、カーソル位置を1つ右にずらします。
  5. デフォルトのカーソル位置:

    return null;
    
    • 上記の条件に当てはまらない場合は、デフォルトのカーソル位置(通常の入力位置)を維持します。

実際の動作例

  • ケース1: テキスト末尾で数字を入力

    • 入力前: 1,234(カーソル位置: 5)
    • 入力後: 12,345(カーソル位置: 6)
    • 解説: テキスト末尾で入力が行われたので、カーソルを新しいテキストの末尾に移動。
  • ケース2: テキスト中間で数字を入力

    • 入力前: 12,345(カーソル位置: 2)
    • 入力後: 123,345(カーソル位置: 3)
    • 解説: カンマが追加されたので、カーソル位置を1つ右に調整。
  • ケース3: カンマの直前で削除

    • 入力前: 1,234(カーソル位置: 2)
    • 入力後: 123(カーソル位置: 1)
    • 解説: カンマが削除されたので、カーソル位置を調整。

コピペ

仕様によりけりですが、今回はコピーしたときに区切り文字は削除するようにします。

final data = await Clipboard.getData('text/plain');
setState(() {
    copiedText = data?.text?.replaceAll(',', '') ?? 'no data';
});

Q&A

Q1. カンマ区切り以外のフォーマットにも対応できますか?

A1. はい、_formatメソッドを変更することで他のフォーマットにも対応可能です。例えば、ドット区切りやスペース区切りなどにも応用できます。

Q2. 小数点以下の数値も扱えますか?

A2. 現在の実装では整数のみですが、小数点以下を扱いたい場合は_formatメソッドと正規表現を修正する必要があります。

まとめ

FlutterでカスタムのTextInputFormatterを実装することで、ユーザーの入力体験を向上させることができます。リアルタイムでのカンマ区切りやカーソル位置の調整など、細かな部分まで制御可能です。ぜひプロジェクトに取り入れてみてください。ただ、結構ややこしいので、参考になればと思います。

参考

ソース(main.dartにコピペして動作確認用)

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Flutter Demo',
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  var copiedText = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: TextField(
                maxLines: 1,
                maxLength: 30 + 8,
                inputFormatters: const [CurrencyFormatter()],
                decoration: InputDecoration(
                  labelText: '金額',
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(12.0),
                  ),
                ),
              ),
            ),
            FilledButton(
                onPressed: () async {
                  final data = await Clipboard.getData('text/plain');
                  setState(() {
                    copiedText = data?.text?.replaceAll(',', '') ?? 'no data';
                  });
                },
                child: const Text('ペースト')),
            Text('コピーされたテキスト: $copiedText'),
          ],
        ),
      ),
    );
  }
}

class CurrencyFormatter extends TextInputFormatter {
  const CurrencyFormatter();

  @override
  TextEditingValue formatEditUpdate(
      TextEditingValue oldValue, TextEditingValue newValue) {
    final newFormattedText = _format(newValue.text);
    final calculatedTextSelection = _calculateSelectionOffset(
      oldValue: oldValue,
      newValue: newValue,
      newText: newFormattedText,
    );

    return newValue.copyWith(
      text: newFormattedText,
      selection: calculatedTextSelection,
    );
  }

  String _format(String input) {
    if (input.isEmpty) {
      return input;
    }
    final value = input.replaceAll(',', '');
    return value.toString().split('').reversed.reduce((result, e) {
      return e + (result.length % 4 == 3 ? ',' : '') + result;
    });
  }

  TextSelection? _calculateSelectionOffset({
    required TextEditingValue oldValue,
    required TextEditingValue newValue,
    required String newText,
  }) {
    final oldOffset = oldValue.selection.baseOffset;
    final newOffset = newValue.selection.baseOffset;

    // オフセットの場所が文字数より多い場所にある場合
    // → 文末の文字を削除したとき
    if (newText.length < newOffset || newText.length < oldOffset) {
      return TextSelection.collapsed(offset: newText.length);
    }

    // 古いオフセットが古いテキストの長さに等しい場合、新しいテキストの末尾にオフセットを移動します。
    // → 文末に文字を追加したとき
    if (oldValue.text.length == oldOffset) {
      return TextSelection.collapsed(offset: newText.length);
    }

    final differenceNumberOfBreakCharacter =
        ','.allMatches(newText).length - ','.allMatches(oldValue.text).length;

    // 分割文字の差に基づいてオフセットを増やすことで調整する
    // → カーソルの後ろに分割文字があるときに、分割文字が増えたとき
    if (differenceNumberOfBreakCharacter == 1 &&
        newText[newOffset + 1] == ',') {
      return TextSelection.collapsed(offset: newOffset + 1);
    }

    return null;
  }
}