🚨

【Flutter】はたらくバリデーション

2022/07/24に公開2

はたらくバリデーション

皆さんは、Flutterで入力フォームのバリデーションをどのように記載してますか?
おそらく、多くの人はデフォルトで用意されているTextFormFieldautovalidateModevalidatorを使っていると思います。
標準のものを使うことももちろん悪くないですが、独自のバリデーションを入れたいときがあると思います。
その時にバリデーションをどう記述するかを悩んだことでしょう。
今回はカスタムしたバリデーションの記述方法と、そのバリデーションがちゃんと機能するかどうかを担保するUTについて記載していこうと思います。

本記事に登場するコードは私のGitHubにプッシュしています。
本記事では関係ないコードは割愛しておりますので、全コードは以下を参考にしてください。

https://github.com/nowvilla-physi/zenn-article/tree/feature/custom-validation

カスタムバリデーションの導入手順

導入手順は以下となります。

  1. カスタムしたバリデーターを作成する。
  2. カスタムしたTextFormFieldを作成する。
  3. 1,2を画面から呼び出す。

フォルダ構成

libフォルダの構成は以下となっております。
どこにファイルを配置すればよいかをここを参考にしてみてください。

lib
├── app.dart
├── importer.dart
├── main.dart
├── component
│   └── custom_text_form.dart
├── constant
│   ├── colors.dart
│   └── strings.dart
├── validator
│   ├── age_validator.dart
│   ├── max_length_validator.dart
│   ├── required_validator.dart
│   └── validator.dart
└── view
    ├── home_page.dart
    └── next_page.dart

カスタムしたバリデーターを作成する。

今回は、名前と年齢を入力する項目を作る想定とします。
バリデーションの仕様としては、以下の3つを用意します。

  • 必須チェック
  • 最大長チェック
  • 年齢フォーマット(正の整数であるかどうか)

名前に関しては、「必須チェック」と「最大長チェック」をバリデーションします。
年齢に関しては、「必須チェック」と「フォーマットチェック」をバリデーションします。

abstractクラスであるValidator

まずは、各バリデーションクラスで振る舞いを統一できるように、abstractクラスであるValidatorを作りましょう。

validator.dart
abstract class Validator<T> {
  bool validate(T value);

  String getMessage();
}

このValidatorを実装したクラスではバリーデーションを実施する関数とエラーメッセージを返す関数のみを定義することとします。
<T>としているのは、ジェネリクスを使用しております。

必須チェック

必須チェックを判定するクラスを作ります。

required_validator.dart
import 'package:sample/importer.dart';

class RequiredValidator implements Validator<String?> {
  
  bool validate(String? value) {
    if (value == null) {
      return false;
    }

    return value.trim().isNotEmpty;
  }

  
  String getMessage() => Strings.requiredValidatorMessage;
}

やっていることは、受け取った値がnullかどうかと空かどうかを判定しています。
Strings.requiredValidatorMessageについては後述します。

最大長チェック

最大長チェックを判定するクラスを作ります。

max_length_validator.dart
import 'package:sample/importer.dart';

class MaxLengthValidator implements Validator<String> {
  final int maxLength;

  MaxLengthValidator(this.maxLength);

  
  bool validate(value) => value.length <= maxLength;

  
  String getMessage() => '$maxLength${Strings.maxLengthValidatorMessage}';
}

やっていることは、受け取った値が指定した文字数以下かどうかを判定しています。
最大長の値は呼び出し側から指定できるようにしています。
Strings.maxLengthValidatorMessageについては後述します。

年齢フォーマットチェック(正の整数であるかどうか)

年齢フォーマットチェック(正の整数であるかどうか)を判定するクラスを作ります。

age_validator.dart
import 'package:sample/importer.dart';

class AgeValidator implements Validator<String> {
  
  bool validate(String value) {
    final age = int.tryParse(value);
    if (age == null) {
      return false;
    }

    return age >= 0;
  }

  
  String getMessage() => Strings.ageValidatorMessage;
}

やっていることは、受け取った値をintにパースできるかどうかを判定しています。
Strings.ageValidatorMessageについては後述します。

Strings.xxxxxについて

以下の3つは、別ファイルで管理しましょう。
文字列のハードコードはできる限り控えましょう。
私は以下のように文字列リソースをstaticで定義することが多いです。

strings.dart
class Strings {
  static const ageValidatorMessage = '年齢のフォーマットが不正です。';
  static const maxLengthValidatorMessage = '文字以下で入力をして下さい。';
  static const requiredValidatorMessage = '入力項目は必須項目です。';
}

カスタムしたTextFormFieldを作成する。

次にカスタムしたTextFormFieldを作っていきましょう。
まずはコードを載せておきます。

custom_text_filed.dart
import 'package:flutter/material.dart';
import 'package:sample/importer.dart';

class CustomTextField extends StatefulWidget {
  final String label;
  final List<Validator> validators;
  final Function onChange;
  final Function setIsValid;

  const CustomTextField({
    Key? key,
    required this.label,
    required this.validators,
    required this.onChange,
    required this.setIsValid,
  }) : super(key: key);

  
  State<CustomTextField> createState() => _CustomTextFieldState();
}

class _CustomTextFieldState extends State<CustomTextField> {
  /// エラーテキスト
  String? _errorText;

  /// バリデーションを実施する
  void _validate(String value) {
    widget.onChange(value);
    final result = widget.validators
        .where((validator) => validator.validate(value) == false)
        .toList();
    if (result.isNotEmpty) {
      _errorText = result.first.getMessage();
      widget.setIsValid(false);
    } else {
      _errorText = null;
      widget.setIsValid(true);
    }
  }

  /// エラーによって色をだし分ける
  Color _bindColor() {
    if (_errorText == null) {
      return AppColors.mainColor;
    } else {
      return AppColors.error;
    }
  }

  
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        TextFormField(
          decoration: InputDecoration(
            label: Text(widget.label, style: TextStyle(color: _bindColor())),
          ),
          onChanged: (String value) {
            _validate(value);
          },
        ),
        _errorText != null
            ? Text(
                _errorText!,
                style: const TextStyle(color: AppColors.error),
              )
            : const SizedBox()
      ],
    );
  }
}

まずは、以下の3つを受け取るようにします。

  final List<Validator> validators;
  final Function onChange;
  final Function setIsValid;
  • validatorsは1で作った独自のバリデーターのリストです。
  • onChangeは入力フォームの値が変わったときに実行する処理を渡します。
  • setIsValidはバリデーションの結果をメモリに保持します。

次にバリデートをする関数を用意します。
引数で受け取ったvalueは入力フォームの値が入ってきます。
validatorswherevalidator.validate(value) == falseのものだけフィルターします。
つまり、バリデーションに引っかかったものだけがresultに格納されます。
そして、resultの空かどうかを判定して、エラーテキストをセットし、setIsValidで有効かどうかを保持します。
result.first.getMessage();では、エラーメッセージとしてフィルターしたリストの最初のものを使用しています。

  void _validate(String value) {
    widget.onChange(value);
    final result = widget.validators
        .where((validator) => validator.validate(value) == false)
        .toList();
    if (result.isNotEmpty) {
      _errorText = result.first.getMessage();
      widget.setIsValid(false);
    } else {
      _errorText = null;
      widget.setIsValid(true);
    }
  }

_validateonChangedで呼ぶようにします。
すると、入力値が変わるごとにバリデーションが実施されます。

          onChanged: (String value) {
            _validate(value);
          },

最後に、_errorTextがnullでない(=バリデーションエラーあり)ならば、Text()でエラーメッセージを表示します。

        _errorText != null
            ? Text(
                _errorText!,
                style: const TextStyle(color: AppColors.error),
              )
            : const SizedBox()

1,2を画面から呼び出す。

1,2で作ったバリデーションを画面がら呼び出しましょう。
まずはコードを載せておきます。

home_page.dart
import 'package:flutter/material.dart';
import 'package:sample/importer.dart';

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  /// 入力する名前
  String _name = '';

  /// 入力する年齢
  String _age = '';

  /// 名前のバリデーション結果
  bool _isValidName = false;

  /// 年齢のバリデーション結果
  bool _isValidAge = false;

  /// 入力した名前をステートに保持する
  void _setName(String name) {
    setState(() {
      _name = name;
    });
  }

  /// 入力した年齢をステートに保持する
  void _setAge(String age) {
    setState(() {
      _age = age;
    });
  }

  ///  名前のバリデーションの結果をステートに保持する
  void _setIsValidName(bool isValid) {
    setState(() {
      _isValidName = isValid;
    });
  }

  ///  年齢のバリデーションの結果をステートに保持する
  void _setIsValidAge(bool isValid) {
    setState(() {
      _isValidAge = isValid;
    });
  }

  /// 全てのバリデーション結果を返す
  bool _isAllValid() {
    return _isValidName && _isValidAge;
  }

  void toNextPage() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) {
          return const NextPage();
        },
      ),
    );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ホーム画面')),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text('以下の項目を入力してください'),
              CustomTextField(
                label: '名前',
                onChange: _setName,
                validators: [
                  RequiredValidator(),
                  MaxLengthValidator(10),
                ],
                setIsValid: _setIsValidName,
              ),
              CustomTextField(
                label: '年齢',
                onChange: _setAge,
                validators: [
                  RequiredValidator(),
                  AgeValidator(),
                ],
                setIsValid: _setIsValidAge,
              ),
              ElevatedButton(
                onPressed: _isAllValid()
                    ? () {
                        toNextPage();
                      }
                    : null,
                child: const Text('次へ'),
              )
            ],
          ),
        ),
      ),
    );
  }
}

コメントしている通りですが、各入力値を保持するプロパティを定義します。

  /// 入力する名前
  String _name = '';

  /// 入力する年齢
  String _age = '';

コメントしている通りですが、各入力値のバリデーションを結果を保持するプロパティを定義します。

  /// 名前のバリデーション結果
  bool _isValidName = false;

  /// 年齢のバリデーション結果
  bool _isValidAge = false;

コメントしている通りですが、入力した各入力値を保持する関数を定義します。
これらはCustomTextFieldonChangeに渡します。

  /// 入力した名前をステートに保持する
  void _setName(String name) {
    setState(() {
      _name = name;
    });
  }

  /// 入力した年齢をステートに保持する
  void _setAge(String age) {
    setState(() {
      _age = age;
    });
  }

コメントしている通りですが、入力した各入力値のバリデーション結果を保持する関数を定義します。
これらはCustomTextFieldsetIsValidに渡します。

  /// 名前のバリデーションの結果をステートに保持する
  void _setIsValidName(bool isValid) {
    setState(() {
      _isValidName = isValid;
    });
  }

  /// 年齢のバリデーションの結果をステートに保持する
  void _setIsValidAge(bool isValid) {
    setState(() {
      _isValidAge = isValid;
    });
  }

コメントしている通りですが、各入力値のバリデーション結果が全てOKかどうかを返します。
これは次の画面に遷移するボタンElevatedButtonで使用します。
_isAllValid()がtrueならば、次へボタンが活性、falseならば非活性となります。

  /// 全てのバリデーション結果を返す
  bool _isAllValid() {
    return _isValidName && _isValidAge;
  }

CustomTextFieldの使い方は以下となります。

  • labelに入力フォームのラベルを指定します。
  • onChangeに入力値が変わったときに呼ぶ関数を指定します。
  • validatorsに1で作ったバリデーターをリスト形式で指定します。
    • 最初に指定したバリデーターから評価されます。
  • setIsValidにバリデーション結果を保持する関数を指定します。
              CustomTextField(
                label: '名前',
                onChange: _setName,
                validators: [
                  RequiredValidator(),
                  MaxLengthValidator(10),
                ],
                setIsValid: _setIsValidName,
              ),
              CustomTextField(
                label: '年齢',
                onChange: _setAge,
                validators: [
                  RequiredValidator(),
                  AgeValidator(),
                ],
                setIsValid: _setIsValidAge,
              ),

labelやメッセージなどわかりやすいようにハードコードしていますが、できる限りstrings.dartにリソース定義してください。

本内容とあまり関係ありませんが、NextPageも載せておきます。

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

class NextPage extends StatefulWidget {
  const NextPage({Key? key}) : super(key: key);

  
  State<NextPage> createState() => _NextPageState();
}

class _NextPageState extends State<NextPage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('ネクスト画面')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('ネクストページです。'),
            ElevatedButton(
              onPressed: () {
                Navigator.pop(context);
              },
              child: const Text('戻る'),
            )
          ],
        ),
      ),
    );
  }
}

importerは以下です。

importer.dart
export 'app.dart';
export 'component/custom_text_form.dart';
export 'constant/colors.dart';
export 'constant/strings.dart';
export 'validator/age_validator.dart';
export 'validator/max_length_validator.dart';
export 'validator/required_validator.dart';
export 'validator/validator.dart';
export 'view/home_page.dart';
export 'view/next_page.dart';

動作確認

以下のように動作します。
入力値によってちゃんとバリデーションされています。

UT

バリーデータのUTを実装します。
まずはRequiredValidatorのUTです。

required_validator_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:sample/importer.dart';

void main() {
  group('RequiredValidatorTest', () {
    test('入力値がnullで無効となること', () {
      // Given.
      final requiredValidator = RequiredValidator();

      // Test.
      final actual = requiredValidator.validate(null);

      // Check.
      expect(actual, false);
    });

    test('入力値が「テスト文字列」で有効となること', () {
      // Given.
      final requiredValidator = RequiredValidator();

      // Test.
      final actual = requiredValidator.validate('テスト文字列');

      // Check.
      expect(actual, true);
    });

    test('入力値が空文字で無効となること', () {
      // Given.
      final requiredValidator = RequiredValidator();

      // Test.
      final actual = requiredValidator.validate('');

      // Check.
      expect(actual, false);
    });

    test('入力値が半角空白で無効となること', () {
      // Given.
      final requiredValidator = RequiredValidator();

      // Test.
      final actual = requiredValidator.validate(' ');

      // Check.
      expect(actual, false);
    });

    test('入力値が全角空白で無効となること', () {
      // Given.
      final requiredValidator = RequiredValidator();

      // Test.
      final actual = requiredValidator.validate(' ');

      // Check.
      expect(actual, false);
    });

    test('バリデーション無効のメッセージを返すこと', () {
      // Given.
      final requiredValidator = RequiredValidator();

      // Test.
      final actual = requiredValidator.getMessage();

      // Check.
      expect(actual, Strings.requiredValidatorMessage);
    });
  });
}

次にMaxLengthValidatorのUTです。

max_length_validator.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:sample/importer.dart';

void main() {
  group('MaxLengthValidatorTest', () {
    test('入力値が9文字で有効となること', () {
      // Given.
      final maxLengthValidator = MaxLengthValidator(10);

      // Test.
      final actual = maxLengthValidator.validate('123456789');

      // Check.
      expect(actual, true);
    });

    test('入力値が10文字で有効となること', () {
      // Given.
      final maxLengthValidator = MaxLengthValidator(10);

      // Test.
      final actual = maxLengthValidator.validate('1234567890');

      // Check.
      expect(actual, true);
    });

    test('入力値が11文字で無効となること', () {
      // Given.
      final maxLengthValidator = MaxLengthValidator(10);

      // Test.
      final actual = maxLengthValidator.validate('12345678901');

      // Check.
      expect(actual, false);
    });

    test('バリデーション無効のメッセージを返すこと', () {
      // Given.
      final maxLengthValidator = MaxLengthValidator(10);

      // Test.
      final actual = maxLengthValidator.getMessage();

      // Check.
      expect(actual, '10${Strings.maxLengthValidatorMessage}');
    });
  });
}

最後にAgeValidatorのUTです。

age_validator.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:sample/importer.dart';

void main() {
  group('MaxLengthValidatorTest', () {
    test('入力値が「24」で有効となること', () {
      // Given.
      final ageValidator = AgeValidator();

      // Test.
      final actual = ageValidator.validate('24');

      // Check.
      expect(actual, true);
    });

    test('入力値が「0」で有効となること', () {
      // Given.
      final ageValidator = AgeValidator();

      // Test.
      final actual = ageValidator.validate('0');

      // Check.
      expect(actual, true);
    });

    test('入力値が「-1」で無効となること', () {
      // Given.
      final ageValidator = AgeValidator();

      // Test.
      final actual = ageValidator.validate('-1');

      // Check.
      expect(actual, false);
    });

    test('入力値が「10.0」で無効となること', () {
      // Given.
      final ageValidator = AgeValidator();

      // Test.
      final actual = ageValidator.validate('10.0');

      // Check.
      expect(actual, false);
    });

    test('入力値が「アイウエオ」で有効となること', () {
      // Given.
      final ageValidator = AgeValidator();

      // Test.
      final actual = ageValidator.validate('アイウエオ');

      // Check.
      expect(actual, false);
    });
  });
}

まとめ

この記事では独自のバリデーションを作る方法を紹介しました。
ぜひ、いいね!と思った方は活用してみてください!!

Discussion

めろんぺんめろんぺん

子ウィジェットのCustomTextFieldのバリデート結果が親要素の_setIsValid○○に返るのってどういう理由なんですか?

__Tomoki____Tomoki__

CustomTextFieldに関数を渡しているので、onchangeでテキストの内容が変わるたびに、その渡している関数が呼ばれるからです。