🎂

生年月日を入力する良い感じの UI を Flutter で作りたかった

2024/12/03に公開

この記事は jig.jp Advent Calender 2024 の 3 日目の記事です。

3 日目は macoshita がお送りします。最近は趣味でも Flutter を書くことが多いです。それではよろしくおねがいします。

概要

日付を入力する UI は、入力させる情報、特に「近い未来・過去を入力させる」のか、「遠い未来・過去を入力させる」のかで、適切な UI が大きく異なります。

Material 3 の Date pickers でも、Modal date picker というカレンダー型のコンポーネントは「生年月日などの遠い過去を入力させないこと」とあり、代わりに Modal date input というテキストフィールド型のコンポーネントを使うように記載されています。
上記ページの年を変更する UI を見ても 1970 や 1980 といった数字は明らかに入力しにくそうですし、納得ですね。生年月日の場合は特に、曜日を見て入力するような情報でもないため、カレンダー UI を使う意味はなさそうです。

Modal date input は日付をキーボードによるテキスト入力で入力させるものです。ユーザーが入力するのは数字だけで、スラッシュやハイフンなどの区切り文字は自動で補完されるように実装されていることが多いかと思います(例えば 1999/12/31 と入力したいときは 19991231 と打てば良い)。

「Modal date input」相当のウィジェットは、残念ながらまだ Flutter には実装されていません。※関連 Issue: https://github.com/flutter/flutter/issues/114088
また、この記事でトライしている UI はこれではありません(ここまで読んで、こちらを期待された方はすみません)。
Flutter の TextField には inputFormatters という入力された値のフォーマットをカスタマイズできるパラメータが備わっていて、これを使えば実装することが出来ますので、気になった方はトライしてみてください。

作ったもの

今回は、「年月日のフィールドが別れているが、キーボードだけで自然に操作できる UI」にトライしてみました。まずは実際に動かしてみたものをご確認ください。

下記のような特徴を備えていることが分かるかと思います。

  • 規定量入力すると次のフィールドに自動で遷移する
  • フィールドが空になった状態で delete キーを押すと前のフィールドに戻れる

この入力方法の利点は下記のようなものが挙げられます。

  • 年・月・日をどこに入力すればよいかわかりやすい(海外の場合 MM/DD/YYYY だったりする)
  • 入力後に年だけ修正したい場合、タップでそのフィールドに移動できる
  • 1〜9月生まれの人、1〜9日生まれの人が、先頭に 0 をつけなくても入力できる(これが一番の利点だと思っています。0 をつけることを理解してもらえるか不安に思わなくてよい)

上記が期待通りに動けば良さそうです……が、残念ながらこの記事の実装では、iOS で delete キーで前のフィールドに戻る部分が動作しません。また Android でもおそらくOS/機種によっては delete キーが動かない可能性があります。詳しい理由は後述します。

実装解説

delete キーで戻る(動かない)

下記のコードがそれをやろうとしたコードです。

    return Focus(
      canRequestFocus: false,
      onKeyEvent: (node, event) {
        // テキストが空のときに delete キーが押されたら、前のフィールドにフォーカスを移動する
        if (widget.hasPreviousField &&
            event is KeyDownEvent &&
            event.logicalKey == LogicalKeyboardKey.backspace &&
            _controller.text.isEmpty) {
          node.previousFocus();
          return KeyEventResult.skipRemainingHandlers;
        }
        return KeyEventResult.ignored;
      },
      child: TextFormField(

結論を書いてしまうと Focus#onKeyEvent や KeyboardListener はハードウェアキーボードの入力を検知するものであるため、基本的には動かないハズということになります。
ところが、一部 Android 端末ではソフトウェアキーボードのイベントをキャッチしてしまう Issue があり、うまく動いてしまう場合があるということでした。

Focus#onKeyEvent のドキュメントは以下となりますが、確かにソフトキーボードをサポートしていないと書かれています。
https://api.flutter.dev/flutter/widgets/Focus/onKeyEvent.html

また、未検証なので申し訳ないですが、デスクトップアプリではこのコードはうまく動作するのではないかと思います。もしデスクトップアプリを作成していて、このような UI を作りたい場合はお試しください。

次のフィールドに自動でフォーカス移動

以下のコードで「自動でのフォーカス移動」を行っています。

      child: TextFormField(
        ...
        autovalidateMode: AutovalidateMode.onUnfocus,
        onChanged: (value) {
          widget.onChanged(value);
          if (value.characters.length == widget.maxLength &&
              _key.currentState!.validate()) {
            if (widget.textInputAction == TextInputAction.next) {
              FocusScope.of(context).nextFocus();
            }
          }
        },

バリデーションが走ってエラー表示がでるタイミングが適切になるよう工夫しています。
2000 と入力するのに 2 まで入力した時点で「年を入力してください」というエラー表示がでるのは煩わしいため、4 桁入力されたときに初めてバリデーションを行うようにしています。
また、1〜9月生まれの人は、1 桁入力してフォーカス移動ボタンを押すという操作があり得るため、 AutovalidateMode.onUnfocus を指定して、フォーカスが外れたときにもバリデーションを行うようにしています。

エラー表示をまとめる

      child: TextFormField(
        ...
        // 横幅が狭いので、エラーメッセージは別で表示させ、TextFormField の下にはエラー表示したくないが、
        // TextFormField にはそういった機能がないため、workaround として errorStyle で高さを 0.01 にして、
        // かつ validator も空文字を返すことで、非表示にしている。
        // 参考: https://github.com/flutter/flutter/issues/54104
        decoration: InputDecoration(
          border: const OutlineInputBorder(),
          labelText: widget.labelText,
          errorStyle: const TextStyle(height: 0.01),
        ),
        validator: (value) {
          final error = widget.validator(value);
          widget.onValidated(error);
          return error != null ? '' : null;
        },

コメントしてある通り、TextFormField は「下にエラー表示を出さない」というオプションがありません。
これに対して、かなり昔から「errorStyle の height に 1 未満の数字を指定し、かつ validator も空文字を返せば非表示にできる」という workaround が使われていたようで、今回はそれを採用しています。

このオプションが追加される可能性については、「Material Design 通りだから対応しない」といったスタンスで閉じられている Issue もあったようなのですが、現在は「簡単なカスタマイズはできるべき」というスタンスになり、P2 のラベルが付いた Issue もあるので、追加される可能性はありそうです。PR を作ってみるのも良いかもしれません。

小ネタ: Dart で月末を取得

DateTime(_year!, _month! + 1, 0).day

DateTime(求めたい年, 求めたい月 + 1, 0) で求めたい年月の月末の DateTime を取得できます。
DateTime コンストラクタの第 3 引数に 1 を指定すると、月の初日の DateTime が生成されますが、0 を指定した場合はその 1 日前の DateTime、つまり前月末日の DateTime が生成されます。
また上記の計算式だと月の部分に 13 を設定してしまうことがありますが、こちらもエラーにはならず、翌年 1 月と解釈されます。

  print(DateTime(2000, 12, 0)); // 2000-11-30 00:00:00.000
  print(DateTime(2000, 13, 1)); // 2001-01-01 00:00:00.000

コード全文

最後に、コード全文を載せておきます。
なお、データの取扱やバリデーションについては https://docs.flutter.dev/cookbook/forms/validation をご参考いただいたほうが良いかと思いますし、flutter_hooks や riverpod を活用したほうがより読みやすいコードになるかと思います。

コード全文
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  
  Widget build(BuildContext context) {
    return const Scaffold(
      body: SafeArea(
        child: Padding(
          padding: EdgeInsets.symmetric(horizontal: 16),
          child: _DateOfBirthForm(),
        ),
      ),
    );
  }
}

class _DateOfBirthForm extends StatefulWidget {
  const _DateOfBirthForm();

  
  State<_DateOfBirthForm> createState() => _DateOfBirthFormState();
}

class _DateOfBirthFormState extends State<_DateOfBirthForm> {
  final _formKey = GlobalKey<FormState>();

  int? _year;
  int? _month;
  int? _day;

  String? _yearError;
  String? _monthError;
  String? _dayError;

  String get _errorText =>
      [_yearError, _monthError, _dayError].where((e) => e != null).join('\n');

  
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Form(
      key: _formKey,
      child: Column(
        children: [
          const SizedBox(height: 16),
          Text('生年月日を入力してください', style: theme.textTheme.titleLarge),
          const SizedBox(height: 16),
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Row(
                children: [
                  Expanded(
                    child: _TextField(
                      labelText: '年',
                      maxLength: 4,
                      hasPreviousField: false,
                      textInputAction: TextInputAction.next,
                      validator: (v) {
                        if (v == null || v.isEmpty) {
                          return '年を入力してください';
                        }
                        final year = int.tryParse(v);
                        if (year == null) {
                          return '年を半角数字で入力してください';
                        }
                        final maxValue = DateTime.now().year - 1;
                        if (year < 1900 || year > maxValue) {
                          return '年を1900〜$maxValueの間で入力してください';
                        }
                        return null;
                      },
                      onValidated: (error) {
                        setState(() {
                          _yearError = error;
                        });
                      },
                      onChanged: (v) {
                        _year = int.tryParse(v ?? '');
                      },
                      onSaved: (value) => _year = int.tryParse(value!),
                    ),
                  ),
                  const SizedBox(width: 16),
                  Expanded(
                    child: _TextField(
                      labelText: '月',
                      maxLength: 2,
                      textInputAction: TextInputAction.next,
                      validator: (v) {
                        if (v == null || v.isEmpty) {
                          return '月を入力してください';
                        }
                        final month = int.tryParse(v);
                        if (month == null) {
                          return '月を半角数字で入力してください';
                        }
                        if (month < 1 || month > 12) {
                          return '月を1〜12の間で入力してください';
                        }
                        return null;
                      },
                      onValidated: (error) {
                        setState(() {
                          _monthError = error;
                        });
                      },
                      onChanged: (v) {
                        _month = int.tryParse(v ?? '');
                      },
                      onSaved: (value) => _month = int.tryParse(value!),
                    ),
                  ),
                  const SizedBox(width: 16),
                  Expanded(
                    child: _TextField(
                      labelText: '日',
                      maxLength: 2,
                      textInputAction: TextInputAction.done,
                      validator: (v) {
                        if (v == null || v.isEmpty) {
                          return '日を入力してください';
                        }
                        final day = int.tryParse(v);
                        if (day == null) {
                          return '日を半角数字で入力してください';
                        }
                        // 年月が入力済みなら、その月の月末を最大値にする
                        final maxValue = _year == null || _month == null
                            ? 31
                            : DateTime(_year!, _month! + 1, 0).day;
                        if (day < 1 || day > maxValue) {
                          return '日を1〜$maxValueの間で入力してください';
                        }
                        return null;
                      },
                      onValidated: (error) {
                        setState(() {
                          _dayError = error;
                        });
                      },
                      onChanged: (v) {
                        _day = int.tryParse(v ?? '');
                      },
                      onSaved: (value) => _day = int.tryParse(value!),
                    ),
                  ),
                ],
              ),
              if (_errorText.isNotEmpty) ...[
                const SizedBox(height: 4),
                Text(
                  _errorText,
                  style: const TextStyle(color: Colors.red),
                ),
              ]
            ],
          ),
          const SizedBox(height: 16),
          FilledButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _formKey.currentState!.save();
                showDialog(
                  context: context,
                  builder: (_) {
                    return AlertDialog(
                      title: const Text('入力内容'),
                      content: Text('$_year$_month$_day 日'),
                      actions: [
                        TextButton(
                          onPressed: () => Navigator.pop(context),
                          child: const Text('OK'),
                        ),
                      ],
                    );
                  },
                );
              }
            },
            child: const Text('送信'),
          ),
        ],
      ),
    );
  }
}

class _TextField extends StatefulWidget {
  const _TextField({
    required this.labelText,
    required this.maxLength,
    this.hasPreviousField = true,
    required this.textInputAction,
    required this.validator,
    required this.onValidated,
    required this.onChanged,
    required this.onSaved,
  });

  final String labelText;
  final int maxLength;
  final TextInputAction textInputAction;
  final bool hasPreviousField;
  final FormFieldValidator<String> validator;
  final ValueChanged<String?> onValidated;
  final ValueChanged<String?> onChanged;
  final FormFieldSetter<String> onSaved;

  
  State<_TextField> createState() => _TextFieldState();
}

class _TextFieldState extends State<_TextField> {
  final _key = GlobalKey<FormFieldState>();

  final _controller = TextEditingController();

  
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Focus(
      canRequestFocus: false,
      onKeyEvent: (node, event) {
        // テキストが空のときに delete キーが押されたら、前のフィールドにフォーカスを移動する
        if (widget.hasPreviousField &&
            event is KeyDownEvent &&
            event.logicalKey == LogicalKeyboardKey.backspace &&
            _controller.text.isEmpty) {
          node.previousFocus();
          return KeyEventResult.skipRemainingHandlers;
        }
        return KeyEventResult.ignored;
      },
      child: TextFormField(
        key: _key,
        controller: _controller,
        keyboardType: TextInputType.number,
        autocorrect: false,
        // 横幅が狭いので、エラーメッセージは別で表示させ、TextFormField の下にはエラー表示したくないが、
        // TextFormField にはそういった機能がないため、workaround として errorStyle で高さを 0.01 にして、
        // かつ validator も空文字を返すことで、非表示にしている。
        // 参考: https://github.com/flutter/flutter/issues/54104
        decoration: InputDecoration(
          border: const OutlineInputBorder(),
          labelText: widget.labelText,
          errorStyle: const TextStyle(height: 0.01),
        ),
        validator: (value) {
          final error = widget.validator(value);
          widget.onValidated(error);
          return error != null ? '' : null;
        },
        textInputAction: widget.textInputAction,
        autovalidateMode: AutovalidateMode.onUnfocus,
        onChanged: (value) {
          widget.onChanged(value);
          if (value.characters.length == widget.maxLength &&
              _key.currentState!.validate()) {
            if (widget.textInputAction == TextInputAction.next) {
              FocusScope.of(context).nextFocus();
            }
          }
        },
        onSaved: widget.onSaved,
      ),
    );
  }
}

iOS でどうにかするアイデア

「delete キーで前のフィールドに戻りたい」と考える人はたくさんいるようで、似たような Issue が立っては閉じられを繰り返しているようです。
その中でも下記の Issue には、いくつかの具体的な実装アイデアが登場します。
https://github.com/flutter/flutter/issues/14809

  • pin_code_fields というパッケージがやっているように、入力は 1 つの透明なテキストフィールドに入力させる
  • ゼロ幅文字のスペースを挿入し、それが削除されたことを検知させる

いずれも「エラー表示をまとめる」で紹介した workaround 以上に壊れやすそうな workaround に感じますが、それでもやらなきゃならない時はよいかも知れません。
私としては「Modal date input 相当のテキストフィールドを採用しつつ、もしソフトウェアキーボードを Flutter がサポートしたら再度検討」というのが落とし所かなと考えています。

jig.jp Engineers' Blog

Discussion