9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

kurogoma939のひとりアドベントカレンダーAdvent Calendar 2024

Day 10

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

Last updated at Posted at 2024-12-09

## はじめに

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

サンプルUI

通常時 バリデーションエラー時
Simulator Screenshot - iPhone 16 - 2024-12-08 at 20.39.29.png Simulator Screenshot - iPhone 16 - 2024-12-08 at 20.39.40.png

コードの状態


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

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

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

/// メールアドレスの部分
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クラスで以下のようになっています

/// 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するときに「戻ってもよろしいですか?」などのアラートを出したい時にも使えそうですね。

コード全体

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
9
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?