📑

【Flutter】入力フォームのボタンを非活性にする、いちばん楽な方法を考える

2024/10/06に公開3

こういうやつ

非活性 活性

Flutter の Form のエラーの課題

Flutter には Form という、入力フォームを構築するためのウィジェットが用意されています。
https://api.flutter.dev/flutter/widgets/Form-class.html

https://api.flutter.dev/flutter/widgets/FormField-class.html

しかし、これらでボタン非活性をハンドリングしようとすると、以下のような壁にぶつかります

  • validate の実行は、StringerrorText が前提になっているので、単純な bool での管理ができない
  • autovalidateMode というものがあるが、TextFormField とかの変更にしか対応してない

Form はエラーテキストの表示を前提にしてるので、単純なボタン非活性のみに使う場合は、少し過剰な機構です。

解決

Form の別の機能、onChanged を使う

Form の別の機能、onChanged を使います。

class _HogeState extends State<Hoge> {
  void _onFormChanged() {
   
  }

  
  Widget build(BuildContext context) {
    return Form(
      onChanged: _onFormChanged,
      child: Column(
        children: [
          TextFormField()
        ]
      )
    );
  }
}

この onChanged は、Form ウィジェットの子孫にある FormField ウィジェットの値に変更があった時に発火します。
ここで言う FormField ウィジェットは TextFormField です。
なので、たとえば 「TextEditingControlleraddListener を追加する」みたいなことをしなくても、簡単に構築できます。

続いて、バリデーションをできるようにしていきます

class _HogeState extends State<Hoge> {
+  // バリデーションの条件式を記述するための、フォームの値
+  final _textEditingController = TextEditingController();

+  // バリデーション実行結果の値
+  bool _isValid = false;

+  // バリデーション条件式
+  bool _validate() {
+    return _textEditingController.text.isNotEmpty;
+  }

  void _onFormChanged() {
+    // バリデーション実行結果の保存
+    final isValid = _validate();
+
+    if (_isValid != isValid) {
+      setState(() {
+        _isValid = isValid;
+      });
+    }
+  }

  
  Widget build(BuildContext context) {
    return Form(
      onChanged: _onFormChanged,
      child: Column(
        children: [
          TextFormField(
+            controller: _textEditingController,
          )
        ]
      )
    );
  }
}

TextFormField 以外の値も管理

FormonChanged で感知できるのは、FormField ウィジェットの値のみです。
しかし、onChanged で管理する方法の利点は、他の値のバリデーションも容易に組み込むことができる点です。

以下は、試しに DateTime の値を取得する自作ウィジェットによりフィールドを追加した例です。

class _HogeState extends State<Hoge> {
  final _textEditingController = TextEditingController();
+ DateTime? birthDate;

+ Future<void> _pickDate() async {
+   final now = DateTime.now();
+   final date = await showDatePicker(
+     context: context,
+     firstDate: DateTime(1970),
+     lastDate: now,
+   );
+
+   if (date != null) {
+     setState(() {
+       _birthDate = date;
+     });
+
+     // _onFormChanged を手動で発火させる
+     _onFormChanged();
+   }
+ }

  // バリデーション実行結果の値
  bool _isValid = false;

  // バリデーション条件式
  bool _validate() {
    return _textEditingController.text.isNotEmpty;
  }

  void _onFormChanged() {
    // バリデーション実行結果の保存
    final isValid = _validate();

    if (_isValid != isValid) {
      setState(() {
        _isValid = isValid;
      });
    }
  }

  
  Widget build(BuildContext context) {
    return Form(
      onChanged: _onFormChanged,
      child: Column(
        children: [
          TextFormField(
            controller: _textEditingController,
          ),
+         Material(
+           child: InkWell(
+             onTap: _pickDate,
+        child: Padding(
+               padding: const EdgeInsets.all(16),
+               child: Row(
+                 children: [
+                   const Icon(Icons.calendar_month),
+                   const SizedBox(width: 16),
+                   if (_birthDate != null)
+                     Text(DateFormat.yMMMEd().format(_birthDate!)),
+                 ],
+               ),
+             ),
+           ),
+         ),
        ]
      )
    );
  }
}

この実装の欠点は、_onFormChanged を手動で発火させなければならない点です。
なので、入力フォームを追加したときに _onFormChanged の実行漏れがあると、テストなどをちゃんとしておかないと、うまく動かなくなるリスクがあります。

しかし、逆に言えば、「値が変わった時に _onFormChanged を毎回実行する」という一つの方針が固まっているので、実装の迷いも軽減され、読むのも簡単になります。

実務的な利点を言えば、大体の値は文字列だったりするので、「TextFormField は勝手にやってくれるので、それ以外のちょっとのやつは _onFormChanged を加える」みたいな方針で書けます。
加えて、どんなウィジェットにも onChanged はほぼ確実に存在するので、不自由することは少ないと思います。

まとめ

  • Form ウィジェットで囲む
  • 文字列のフォームは TextFormFieldを利用する
  • FormonChanged に、バリデーションの真偽値を更新する関数を加える
例のソースコード
class _Example1 extends StatefulWidget {
  const _Example1();

  
  State<_Example1> createState() => _Example1State();
}

class _Example1State extends State<_Example1> {
  final _firstNameController = TextEditingController();
  final _lastNameController = TextEditingController();
  DateTime? _birthDate;

  bool _isValid = false;

  Future<void> _pickDate() async {
    final now = DateTime.now();
    final date = await showDatePicker(
      context: context,
      firstDate: DateTime(1970),
      lastDate: now,
    );

    if (date != null) {
      setState(() {
        _birthDate = date;
      });

      _onFormChanged();
    }
  }

  void _save() {
    final snackBar = SnackBar(
      content: Text(
        '${_birthDate!.year}年生まれの ${_lastNameController.text}${_firstNameController.text} さん',
      ),
    );
    ScaffoldMessenger.of(context).showSnackBar(snackBar);
  }

  bool _validate() {
    return _firstNameController.text.isNotEmpty &&
        _lastNameController.text.isNotEmpty &&
        _birthDate != null;
  }

  void _onFormChanged() {
    setState(() {
      _isValid = _validate();
    });
  }

  
  Widget build(BuildContext context) {
    return Form(
      onChanged: _onFormChanged,
      child: SeparatedColumn(
        gap: 16,
        children: [
          Row(
            children: [
              Expanded(
                child: TextFormField(
                  controller: _lastNameController,
                  decoration: const InputDecoration(
                    labelText: '姓',
                  ),
                ),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: TextFormField(
                  controller: _firstNameController,
                  decoration: const InputDecoration(
                    labelText: '名',
                  ),
                  maxLines: null,
                ),
              ),
            ],
          ),
          Material(
            child: InkWell(
              onTap: _pickDate,
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Row(
                  children: [
                    const Icon(Icons.calendar_month),
                    const SizedBox(width: 16),
                    if (_birthDate != null)
                      Text(DateFormat.yMMMEd().format(_birthDate!)),
                  ],
                ),
              ),
            ),
          ),
          FilledButton(
            onPressed: _isValid ? _save : null,
            style: FilledButton.styleFrom(
              minimumSize: const Size.fromHeight(48),
            ),
            child: const Text('保存'),
          ),
        ],
      ),
    );
  }
}

更なる改善

_isValidValueNotifier に変える

Flutter は再描画のシステムが優秀なので、そこまで気にしすぎなくても良いですが、「フォームの値が変わるたびに setState を実行する」のを解消したい場合は、_isValid の監視範囲を、ValueNotifier で絞ることが有効です。

class _HogeState extends State<Hoge> {
- bool _isValid = false
+ // bool 変数を、ValueNotifier<bool> へ変更
+ bool _isValidNotifier = ValueNotifier<bool>(false);

  // ...中略

  void _onFormChanged() {
+   _isValidNotifier.value = _validate();
  }

  
  Widget build(BuildContext context) {

    // ...中略

    // ValueListenableBuilder で、ValueNotifier を監視
    ValueListenableBuilder(
      valueListenable: _isValidNotifier,
      builder: (context, isValid, child) {
        return FilledButton(
          onPressed: isValid ? _save : null,
          style: FilledButton.styleFrom(
            minimumSize: const Size.fromHeight(48),
          ),
          child: const Text('保存'),
        );
      },
    )
  }
}

全て FormField にする

自作ウィジェットも全て FormField で実装すれば、_onFormChanged を手動で実行しなくても良くなります。

しかし、これだと色々気にすることが増えてしまうので、個人的にはお勧めしません。。

+ FormField<DateTime>(
+  builder: (field) {
    return Material(
      child: InkWell(
        onTap: () async {
          final date = await _pickDate();

          if (date != null) {
            setState(() {
              _birthDate = date;
            });
+           // didChange は、_birthDate を更新した後で、実行しなければならない
+           field.didChange(date);
          }
        },
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Row(
            children: [
              const Icon(Icons.calendar_month),
              const SizedBox(width: 16),
              if (_birthDate != null)
                Text(DateFormat.yMMMEd().format(_birthDate)),
            ],
          ),
        ),
      ),
    );
  },
),

Discussion