【Flutter】はたらくバリデーション
はたらくバリデーション
皆さんは、Flutterで入力フォームのバリデーションをどのように記載してますか?
おそらく、多くの人はデフォルトで用意されているTextFormField
のautovalidateMode
とvalidator
を使っていると思います。
標準のものを使うことももちろん悪くないですが、独自のバリデーションを入れたいときがあると思います。
その時にバリデーションをどう記述するかを悩んだことでしょう。
今回はカスタムしたバリデーションの記述方法と、そのバリデーションがちゃんと機能するかどうかを担保するUTについて記載していこうと思います。
本記事に登場するコードは私のGitHubにプッシュしています。
本記事では関係ないコードは割愛しておりますので、全コードは以下を参考にしてください。
カスタムバリデーションの導入手順
導入手順は以下となります。
- カスタムしたバリデーターを作成する。
- カスタムした
TextFormField
を作成する。 - 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つを用意します。
- 必須チェック
- 最大長チェック
- 年齢フォーマット(正の整数であるかどうか)
名前に関しては、「必須チェック」と「最大長チェック」をバリデーションします。
年齢に関しては、「必須チェック」と「フォーマットチェック」をバリデーションします。
Validator
abstractクラスであるまずは、各バリデーションクラスで振る舞いを統一できるように、abstractクラスであるValidator
を作りましょう。
abstract class Validator<T> {
bool validate(T value);
String getMessage();
}
このValidator
を実装したクラスではバリーデーションを実施する関数とエラーメッセージを返す関数のみを定義することとします。
<T>
としているのは、ジェネリクスを使用しております。
必須チェック
必須チェックを判定するクラスを作ります。
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
については後述します。
最大長チェック
最大長チェックを判定するクラスを作ります。
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
については後述します。
年齢フォーマットチェック(正の整数であるかどうか)
年齢フォーマットチェック(正の整数であるかどうか)を判定するクラスを作ります。
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で定義することが多いです。
class Strings {
static const ageValidatorMessage = '年齢のフォーマットが不正です。';
static const maxLengthValidatorMessage = '文字以下で入力をして下さい。';
static const requiredValidatorMessage = '入力項目は必須項目です。';
}
TextFormField
を作成する。
カスタムした次にカスタムしたTextFormField
を作っていきましょう。
まずはコードを載せておきます。
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
は入力フォームの値が入ってきます。
validators
をwhere
でvalidator.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);
}
}
_validate
をonChanged
で呼ぶようにします。
すると、入力値が変わるごとにバリデーションが実施されます。
onChanged: (String value) {
_validate(value);
},
最後に、_errorText
がnullでない(=バリデーションエラーあり)ならば、Text()
でエラーメッセージを表示します。
_errorText != null
? Text(
_errorText!,
style: const TextStyle(color: AppColors.error),
)
: const SizedBox()
1,2を画面から呼び出す。
1,2で作ったバリデーションを画面がら呼び出しましょう。
まずはコードを載せておきます。
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;
コメントしている通りですが、入力した各入力値を保持する関数を定義します。
これらはCustomTextField
のonChange
に渡します。
/// 入力した名前をステートに保持する
void _setName(String name) {
setState(() {
_name = name;
});
}
/// 入力した年齢をステートに保持する
void _setAge(String age) {
setState(() {
_age = age;
});
}
コメントしている通りですが、入力した各入力値のバリデーション結果を保持する関数を定義します。
これらはCustomTextField
のsetIsValid
に渡します。
/// 名前のバリデーションの結果をステートに保持する
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
も載せておきます。
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は以下です。
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です。
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です。
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です。
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○○に返るのってどういう理由なんですか?
CustomTextFieldに関数を渡しているので、onchangeでテキストの内容が変わるたびに、その渡している関数が呼ばれるからです。