[Flutter]入力フォームのインラインバリデーションを実装する

Form、FormTextFieldを使ってスマートフォンアプリでよくある入力フォームのインラインバリデーションを実装しました。
2021.01.31

問題のあるフィールドのインラインにエラーメッセージを表示するインラインバリデーションについて扱います。

現在個人開発で実装中のアプリの詳細画面でフォーム入力が必要になりました。インラインバリデーションのUXについて書いた記事を見た覚えがあり、使用するか迷いましたが、ボタンタップ時のバリデーションと違ってFlutterではまだ実装したことがないUIだったので今回実装してみました。

Form、FormField

入力フォームを実装する時にTextFormFieldが選択肢に浮かびますが、今回にはバリデーションを簡易に柔軟に行えるTextFormFieldを選択しました。TextFormFieldに関連するwidgetとしてFormがあります。複数のフォームフィールドウィジェットをグループ化するためのウィジェットがFormです。そしてFormの使用はドキュメントでAn optional contianerと記載がある通り必須ではありません。

ドキュメントは以下です。

最初にコードを貼ります。

  • データの永続化を行う部分の実装は取り除きました
  • 説明のために共通のコンポーネントに切り出したりはしておらずベタ書きで記述しています。
import 'package:flutter/material.dart';
import 'package:habity/model/validators/validators.dart';

class SubEditPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    final defaultTextTheme = Theme.of(context).textTheme;
    final titleStyle = defaultTextTheme.subtitle1.copyWith(
      fontWeight: FontWeight.bold,
      color: Colors.grey,
    );
    return Scaffold(
        appBar: AppBar(
          title: Text('詳細画面'),
        ),
        body: SingleChildScrollView(
          child: Form(
            child: Container(
                width: double.infinity,
                color: Colors.white,
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Container(
                    decoration: BoxDecoration(
                      border: Border.all(color: Colors.grey),
                      borderRadius: BorderRadius.circular(5),
                    ),
                    child: Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 30.0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Center(
                            child: Column(
                              children: [
                                SizedBox(height: 20),
                                Text('アイコン', style: titleStyle),
                                SizedBox(height: 5),
                                CircleAvatar(
                                  backgroundColor: Colors.grey,
                                  child: Icon(Icons.add, color: Colors.white),
                                  radius: 30,
                                )
                              ],
                            ),
                          ),
                          SizedBox(height: 20),
                          Text('名前', style: titleStyle),
                          TextFormField(
                            cursorColor: Colors.black,
                            maxLength: 30,
                            autovalidateMode:
                                AutovalidateMode.onUserInteraction,
                            validator: NameValidator.validate,
                          ),
                          SizedBox(height: 20),
                          Row(
                            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                            children: [
                              Text('テーマカラー', style: titleStyle),
                              Container(
                                color: Colors.blue,
                                width: 100,
                                height: 30,
                              ),
                            ],
                          ),
                          SizedBox(height: 20),
                          Text('メモ'),
                          TextFormField(
                            cursorColor: Colors.black,
                            maxLength: 300,
                            autovalidateMode:
                                AutovalidateMode.onUserInteraction,
                            validator: DescriptionValidator.validate,
                          ),
                          SizedBox(height: 20),
                          Text('支払いスパン', style: titleStyle),
                          SizedBox(height: 10),
                          Row(
                            children: [
                              Text('Every'),
                              TextButton(
                                onPressed: null,
                                child: Text('未設定'),
                              ),
                              TextButton(
                                onPressed: null,
                                child: Text('未設定'),
                              )
                            ],
                          ),
                          SizedBox(height: 20),
                          Text('期限(期間限定で契約したい場合などにお知らせします)'),
                          TextButton(
                            onPressed: null,
                            child: Text('未設定'),
                          ),
                          SizedBox(height: 20),
                          Text('リマインド'),
                          TextFormField(),
                          SizedBox(height: 20),
                        ],
                      ),
                    ),
                  ),
                )),
          ),
        ));
  }
}

ビルドすると以下のような画面になります。左上のバツボタンは別の画面からfullscreenDialogをtrueにして遷移させているためです。押下すると閉じられます。

名前とメモの下にTextFormFieldを用いています。

TextFormField(
  ncursorColor: Colors.black,
  maxLength: 300,
  autovalidateMode: AutovalidateMode.onUserInteraction,
  validator: DescriptionValidator.validate,
),

ユーザーが入力した時にバリデーションを行いたいのでautoValidateModeをonUserInteractionにします

AutovalidateModeという列挙型でTextFormFieldに入力された値のバリデーションのタイミングを制御します。

/// Used to configure the auto validation of [FormField] and [Form] widgets.
enum AutovalidateMode {
  disabled, // 自動バリデーションを行わない
  always, // ユーザーの入力がなくても自動でバリデーションを行う。全フォーム入力必須な編集画面などでは良いかもしれない.
  onUserInteraction, // 今回はこちら。ユーザーの入力が行われたタイミングでバリデーションを行う。
}

autoValidateというコンストラクタ引数をtrueにすることでAutovalidateMode.alwaysと同じようにできますが、こちらはdeprecatedになっているので使用しないようにします。使用自体は可能ですが、autoValidateを使用すると以下のように警告が標示されます。

'autovalidate' is deprecated and shouldn't be used. Use autoValidateMode parameter which provide more specific behaviour related to auto validation. This feature was deprecated after v1.19.0..

TextFormFieldのコンストラクタ引数validatorでバリデーションロジックを記述します。実装の都合上バリデーションロジックのテストが書きたかったので別ファイルに記述していますが内容は後述します。

次に引数validatorの型を見ます。

FormFieldValidator<String> validator,

Dartでは関数は文字などと同じくオブジェクトなので、変数や戻り値の型名として扱うためにtypedefプレフィックスをつかってエイリアスを宣言します。function type aliasや関数型エイリアスと呼ばれることもあります。

FormFieldValidatorも同じようにtypedefを使って宣言されています。

typedef FormFieldValidator<T> = String Function(T value);

Flutterのコードでよく見るものでRaisedButtonなどにonPressedというコンストラクタ引数がありますがVoidCallbackという型が宣言されていて、これもtypedefが使われています。

typedef VoidCallback = void Function()

コンストラクタ引数validatorは文字列を返すと文字列がエラー用のテキストとして表示されます。nullを返すと正常系として何もエラーは表示されません。

validatorsディレクトリの下にあるvalidators.dartでまとめてvalidatorロジックを提供しているクラスをexportしています。そのためvalidators/validators.dartをimportすれば必要なクラスが使用できます。

export 'description_validator.dart';
export 'name_validator.dart';

名前を入力するフィールドのバリデーションについて記述したクラスが以下です。

import 'validatable.dart';

class NameValidator implements Validatable {
  static String Function(String) validate = (value) {
    if (value == null) {
      return '値が未設定です。';
    }
    if (value.isEmpty) {
      return '値が未設定です。';
    }

    if (value.indexOf(' ') >= 0 && value.trim() == '') {
      return '空文字は受け付けていません。';
    }

    if (value.indexOf(' ') >= 0 && value.trim() == '') {
      return '空文字は受け付けていません。';
    }

    if (value.length > 30) {
      return '30文字以下にしてください';
    }
    return null;
  };
}

nullや空文字が与えられていた場合や規定の文字数以下の場合に文字列を返すようになっています。純粋な関数なのでテストも書きやすいです。

テストコードは以下です。

void main() {
  test('NameValidator\'s validation test', () {
    var result = NameValidator.validate(null);
    expect(result, '値が未設定です。');

    result = NameValidator.validate('');
    expect(result, '値が未設定です。');

    result = NameValidator.validate('     ');
    expect(result, '空文字は受け付けていません。');

    result = NameValidator.validate('jjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj');
    expect(result, '30文字以下にしてください');

    result = NameValidator.validate('     a');
    expect(result, null);

    result = NameValidator.validate('hoge');
    expect(result, null);
  });
}

実際のアプリの動きは以下になります。

ここまででバリデーションに関するところは書き終わったのですが実際にはバリデーションが通った後は保存処理に入ります。そこで、各フォームの値を保存する部分と保存する方法も記載します。

GlobalKey型の値をFormのコンストラクタ引数keyに渡します。これを使って各フォームを一意に識別します。

<br />class SubEditPage extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final _scaffoldKey = GlobalKey<ScaffoldState>(); // SnackBarを表示するためのグローバルキー
    final _formKey = GlobalKey<FormState>(); // フォームを一意に区別するためのキー
    return Scaffold(
        appBar: AppBar(
          title: Text('詳細画面'),
        ),
        key: _scaffoldKey,
        floatingActionButton: FloatingActionButton(
          child: Icon(Icons.edit),
          onPressed: () {
            if (_formKey.currentState.validate()) {
              _formKey.currentState.save(); // 保存される
              _scaffoldKey.currentState
                  .showSnackBar(SnackBar(content: Text('更新完了')));
            }
          },
        ),
        body: SingleChildScrollView(
          child: Form(
            key: _formKey, // キーを渡している

FloatingActionButtonのonPressed引数で念の為バリデーションに問題ないことを確認した後_formKeyのcurrentStateを取り出しsaveメソッドを呼び出しています。と同時にスナックバーを表示しています。

saveが呼ばれた時に各TextFormFieldのonSave引数に渡した処理が実行されます。onSaveの型は以下です。

typedef FormFieldSetter<T> = void Function(T newValue);

TextFormFieldのonSaveに値を渡していなかったので修正が必要です。

TextFormField(
    onSaved: (value) {
      // valueにフォームの値が入る。save()を呼び出さないとonSavedも実行されない
    },
    cursorColor: Colors.black,
    maxLength: 30,
    autovalidateMode:
    AutovalidateMode.onUserInteraction,
    validator: NameValidator.validate,
)

ここまででForm、TextFormFieldを使ったバリデーションとフォームの値の取り出しまで説明しました。

まとめ

個人アプリにFlutterを採用して作り始めましたが特にUIの実装の速度には驚かされます。期待したUIを素早く実現できてすぐに自分の手で触ってみるフェーズまで辿り着けるところが気に入っています。Flutterの学習・開発は始めたばかりで説明やその裏にある認識に誤りがあるかもしれません。なにかお気づきの際はコメントなどでお知らせください。

参考にしたリンク一覧

記事の中で紹介はしませんでしたが、実装や記事執筆中に目を通したドキュメントや記事があるので最後に列挙します。