【Flutter】TextFormのバリデーションUIをカスタムする

##　はじめに

Flutterで標準のTextFormFieldウィジットを用いると以下のようなUIが実装できます。

### サンプルUI

| 通常時 | バリデーションエラー時 |
|:-:|:-:|
| ![Simulator Screenshot - iPhone 16 - 2024-12-08 at 20.39.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/0195670f-b33a-8d93-bbb5-5eca2f73a8df.png)  |  ![Simulator Screenshot - iPhone 16 - 2024-12-08 at 20.39.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/7ae50f78-951b-c989-8a4f-0fd2d58a994c.png) |


### コードの状態


```dart

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();

  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  String? _validateEmail(String? value) {
    if (value == null || value.isEmpty) {
      return 'メールアドレスを入力してください。';
    }
    if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
      return '正しいメールアドレス形式で入力してください。';
    }
    return null;
  }

  String? _validatePassword(String? value) {
    if (value == null || value.isEmpty) {
      return 'パスワードを入力してください。';
    }
    if (value.length < 6) {
      return 'パスワードは6文字以上で入力してください。';
    }
    return null;
  }

  void _onSubmit() {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('バリデーションに成功しました！'))
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    final borderRadius = BorderRadius.circular(12);

    return Scaffold(
      appBar: AppBar(
        title: const Text('ログインフォーム'),
      ),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
        child: Container(
          decoration: BoxDecoration(
            borderRadius: borderRadius,
            color: Theme.of(context).colorScheme.surface,
          ),
          child: Form(
            key: _formKey,
            child: ListView(
              // ListViewを使うことでキーボード表示時のスクロールにも対応しやすい
              padding: const EdgeInsets.symmetric(vertical: 16.0),
              children: [
                TextFormField(
                  controller: _emailController,
                  decoration: InputDecoration(
                    labelText: 'メールアドレス',
                    hintText: 'example@example.com',
                    border: OutlineInputBorder(
                      borderRadius: borderRadius,
                    ),
                    enabledBorder: OutlineInputBorder(
                      borderRadius: borderRadius,
                      borderSide: BorderSide(
                        color: Theme.of(context).colorScheme.outline,
                      ),
                    ),
                    focusedBorder: OutlineInputBorder(
                      borderRadius: borderRadius,
                      borderSide: BorderSide(
                        color: Theme.of(context).colorScheme.primary,
                        width: 2,
                      ),
                    ),
                  ),
                  validator: _validateEmail,
                ),
                const SizedBox(height: 16),
                TextFormField(
                  controller: _passwordController,
                  obscureText: true,
                  decoration: InputDecoration(
                    labelText: 'パスワード',
                    border: OutlineInputBorder(
                      borderRadius: borderRadius,
                    ),
                    enabledBorder: OutlineInputBorder(
                      borderRadius: borderRadius,
                      borderSide: BorderSide(
                        color: Theme.of(context).colorScheme.outline,
                      ),
                    ),
                    focusedBorder: OutlineInputBorder(
                      borderRadius: borderRadius,
                      borderSide: BorderSide(
                        color: Theme.of(context).colorScheme.primary,
                        width: 2,
                      ),
                    ),
                  ),
                  validator: _validatePassword,
                ),
                const SizedBox(height: 32),
                FilledButton(
                  onPressed: _onSubmit,
                  child: const Text('送信'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```

## 課題：バリデーションUIのカスタム方法

例えば、以下のようにバリデーションメッセージをカスタムしたい場合、
もしくは成功時にも何か表示させたいといった場合の紹介です。

![スクリーンショット 2024-12-08 20.46.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/510b4583-b541-d215-3ab6-ef82b73fe2c5.png)

### コード：メールアドレスフィールドの例

先に結論だけ記述すると、FormFieldウィジェットを用います。

```dart
/// メールアドレスの部分
FormField<String>(
  initialValue: '',
  validator: _validateEmail,
  builder: (field) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          onChanged: field.didChange,
          decoration: InputDecoration(
            labelText: 'メールアドレス',
            hintText: 'example@example.com',
            border:
                OutlineInputBorder(borderRadius: borderRadius),
            enabledBorder: OutlineInputBorder(
              borderRadius: borderRadius,
              borderSide: BorderSide(
                color: Theme.of(context).colorScheme.outline,
              ),
            ),
            focusedBorder: OutlineInputBorder(
              borderRadius: borderRadius,
              borderSide: BorderSide(
                color: Theme.of(context).colorScheme.primary,
                width: 2,
              ),
            ),
          ),
        ),
        if (field.hasError)
          Padding(
            padding: const EdgeInsets.only(top: 4.0),
            child: Row(
              children: [
                const Icon(Icons.close,
                    color: Colors.red, size: 20),
                const SizedBox(width: 8),
                Text(
                  field.errorText ?? '',
                  style: const TextStyle(color: Colors.red),
                ),
              ],
            ),
          ),
      ],
    );
  },
),
```

`builder: (field) {`の`field`はFormFieldStateクラスで以下のようになっています

```dart
/// The current state of a [FormField]. Passed to the [FormFieldBuilder] method
/// for use in constructing the form field's widget.
class FormFieldState<T> extends State<FormField<T>> with RestorationMixin {
  late T? _value = widget.initialValue;
  // Marking it as late, so it can be registered
  // with the value provided by [forceErrorText].
  late final RestorableStringN _errorText;
  final RestorableBool _hasInteractedByUser = RestorableBool(false);
  final FocusNode _focusNode = FocusNode();

  /// The current value of the form field.
  T? get value => _value;

  /// The current validation error returned by the [FormField.validator]
  /// callback, or the manually provided error message using the
  /// [FormField.forceErrorText] property.
  ///
  /// This property is automatically updated when [validate] is called and the
  /// [FormField.validator] callback is invoked, or If [FormField.forceErrorText] is set
  /// directly to a non-null value.
  String? get errorText => _errorText.value;

  /// True if this field has any validation errors.
  bool get hasError => _errorText.value != null;

  /// Returns true if the user has modified the value of this field.
  ///
  /// This only updates to true once [didChange] has been called and resets to
  /// false when [reset] is called.
  bool get hasInteractedByUser => _hasInteractedByUser.value;

  /// True if the current value is valid.
  ///
  /// This will not set [errorText] or [hasError] and it will not update
  /// error display.
  ///
  /// See also:
  ///
  ///  * [validate], which may update [errorText] and [hasError].
  ///
  ///  * [FormField.forceErrorText], which also may update [errorText] and [hasError].
  bool get isValid => widget.forceErrorText == null && widget.validator?.call(_value) == null;

  /// Calls the [FormField]'s onSaved method with the current value.
  void save() {
    widget.onSaved?.call(value);
  }

  /// Resets the field to its initial value.
  void reset() {
    setState(() {
      _value = widget.initialValue;
      _hasInteractedByUser.value = false;
      _errorText.value = null;
    });
    Form.maybeOf(context)?._fieldDidChange();
  }

  /// Calls [FormField.validator] to set the [errorText] only if [FormField.forceErrorText] is null.
  /// When [FormField.forceErrorText] is not null, [FormField.validator] will not be called.
  ///
  /// Returns true if there were no errors.
  /// See also:
  ///
  ///  * [isValid], which passively gets the validity without setting
  ///    [errorText] or [hasError].
  bool validate() {
    setState(() {
      _validate();
    });
    return !hasError;
  }

  void _validate() {
    if (widget.forceErrorText != null) {
      _errorText.value = widget.forceErrorText;
      // Skip validating if error is forced.
      return;
    }
    if (widget.validator != null) {
      _errorText.value = widget.validator!(_value);
    } else {
      _errorText.value = null;
    }
  }

  /// Updates this field's state to the new value. Useful for responding to
  /// child widget changes, e.g. [Slider]'s [Slider.onChanged] argument.
  ///
  /// Triggers the [Form.onChanged] callback and, if [Form.autovalidateMode] is
  /// [AutovalidateMode.always] or [AutovalidateMode.onUserInteraction],
  /// revalidates all the fields of the form.
  void didChange(T? value) {
    setState(() {
      _value = value;
      _hasInteractedByUser.value = true;
    });
    Form.maybeOf(context)?._fieldDidChange();
  }

  /// Sets the value associated with this form field.
  ///
  /// This method should only be called by subclasses that need to update
  /// the form field value due to state changes identified during the widget
  /// build phase, when calling `setState` is prohibited. In all other cases,
  /// the value should be set by a call to [didChange], which ensures that
  /// `setState` is called.
  @protected
  // ignore: use_setters_to_change_properties, (API predates enforcing the lint)
  void setValue(T? value) {
    _value = value;
  }

  @override
  String? get restorationId => widget.restorationId;

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_errorText, 'error_text');
    registerForRestoration(_hasInteractedByUser, 'has_interacted_by_user');
  }

  @override
  void deactivate() {
    Form.maybeOf(context)?._unregister(this);
    super.deactivate();
  }

  @override
  void initState() {
    super.initState();
    _errorText = RestorableStringN(widget.forceErrorText);
  }

  @override
  void didUpdateWidget(FormField<T> oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.forceErrorText != oldWidget.forceErrorText) {
      _errorText.value = widget.forceErrorText;
    }
  }

  @override
  void dispose() {
    _errorText.dispose();
    _focusNode.dispose();
    _hasInteractedByUser.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (widget.enabled) {
      switch (widget.autovalidateMode) {
        case AutovalidateMode.always:
          _validate();
        case AutovalidateMode.onUserInteraction:
          if (_hasInteractedByUser.value) {
            _validate();
          }
        case AutovalidateMode.onUnfocus:
        case AutovalidateMode.disabled:
          break;
      }
    }

    Form.maybeOf(context)?._register(this);

    if (Form.maybeOf(context)?.widget.autovalidateMode == AutovalidateMode.onUnfocus && widget.autovalidateMode != AutovalidateMode.always ||
        widget.autovalidateMode == AutovalidateMode.onUnfocus) {
      return Focus(
        canRequestFocus: false,
        skipTraversal: true,
        onFocusChange: (bool value) {
          if (!value) {
            setState(() {
              _validate();
            });
          }
        },
        focusNode: _focusNode,
        child: widget.builder(this),
      );
    }

    return widget.builder(this);
  }

}
```

ハンドリングに必要なものは一式揃っていますが、中でも`hasInteractedByUser`はユーザーが編集をしたかわかるフラグになっているので、編集画面でpopするときに「戻ってもよろしいですか？」などのアラートを出したい時にも使えそうですね。

## コード全体

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Custom Validation with FormField',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const LoginPage(),
    );
  }
}

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();

  String? _validateEmail(String? value) {
    if (value == null || value.isEmpty) {
      return 'メールアドレスを入力してください。';
    }
    if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) {
      return '正しいメールアドレス形式で入力してください。';
    }
    return null;
  }

  String? _validatePassword(String? value) {
    if (value == null || value.isEmpty) {
      return 'パスワードを入力してください。';
    }
    if (value.length < 6) {
      return 'パスワードは6文字以上で入力してください。';
    }
    return null;
  }

  void _onSubmit() {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context)
          .showSnackBar(const SnackBar(content: Text('バリデーションに成功しました！')));
    }
  }

  @override
  Widget build(BuildContext context) {
    final borderRadius = BorderRadius.circular(12);

    return Scaffold(
      appBar: AppBar(
        title: const Text('ログインフォーム'),
      ),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
        child: Form(
          key: _formKey,
          child: ListView(
            children: [
              FormField<String>(
                initialValue: '',
                validator: _validateEmail,
                builder: (field) {
                  return Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      TextField(
                        onChanged: field.didChange,
                        decoration: InputDecoration(
                          labelText: 'メールアドレス',
                          hintText: 'example@example.com',
                          border:
                              OutlineInputBorder(borderRadius: borderRadius),
                          enabledBorder: OutlineInputBorder(
                            borderRadius: borderRadius,
                            borderSide: BorderSide(
                              color: Theme.of(context).colorScheme.outline,
                            ),
                          ),
                          focusedBorder: OutlineInputBorder(
                            borderRadius: borderRadius,
                            borderSide: BorderSide(
                              color: Theme.of(context).colorScheme.primary,
                              width: 2,
                            ),
                          ),
                        ),
                      ),
                      if (field.hasError)
                        Padding(
                          padding: const EdgeInsets.only(top: 4.0),
                          child: Row(
                            children: [
                              const Icon(Icons.close,
                                  color: Colors.red, size: 20),
                              const SizedBox(width: 8),
                              Text(
                                field.errorText ?? '',
                                style: const TextStyle(color: Colors.red),
                              ),
                            ],
                          ),
                        ),
                    ],
                  );
                },
              ),
              const SizedBox(height: 16),
              FormField<String>(
                initialValue: '',
                validator: _validatePassword,
                builder: (field) {
                  return Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      TextField(
                        onChanged: field.didChange,
                        obscureText: true,
                        decoration: InputDecoration(
                          labelText: 'パスワード',
                          border:
                              OutlineInputBorder(borderRadius: borderRadius),
                          enabledBorder: OutlineInputBorder(
                            borderRadius: borderRadius,
                            borderSide: BorderSide(
                              color: Theme.of(context).colorScheme.outline,
                            ),
                          ),
                          focusedBorder: OutlineInputBorder(
                            borderRadius: borderRadius,
                            borderSide: BorderSide(
                              color: Theme.of(context).colorScheme.primary,
                              width: 2,
                            ),
                          ),
                        ),
                      ),
                      if (field.hasError)
                        Padding(
                          padding: const EdgeInsets.only(top: 4.0),
                          child: Row(
                            children: [
                              const Icon(Icons.close,
                                  color: Colors.red, size: 20),
                              const SizedBox(width: 8),
                              Text(
                                field.errorText ?? '',
                                style: const TextStyle(color: Colors.red),
                              ),
                            ],
                          ),
                        ),
                    ],
                  );
                },
              ),
              const SizedBox(height: 32),
              FilledButton(
                onPressed: _onSubmit,
                child: const Text('送信'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

| キャプチャ |
|:-:|
| ![Simulator Screenshot - iPhone 16 - 2024-12-08 at 20.42.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/55e63b33-8d5e-4666-b0d4-2a2513a98b63.png)  |