検索条件、伝票入力、ログインフォームなど、業務アプリの画面は入力フォームの上に積み上がります。土台になるのが、Form と TextFormField を使った入力検証です。必須チェック、形式チェック、エラーメッセージの表示、そして入力が整うまで送信ボタンを押させない制御を、最初に型として押さえておくと、後続の画面が楽になります。この記事では注文フォームを題材に、Form / TextFormField / validator の最小構成を作ります。
題材はメールアドレスと数量を入力する小さなフォームです。外部パッケージは使わず、flutter run でそのまま動きます。
1. ゴールと非対象
対象読者
- 画面は作れるようになったが、入力フォームの検証をどう組むか迷っている人
TextFieldで入力は受け取れるが、エラー表示や送信制御の置き場所が定まらない人flutter testでフォームの挙動まで確かめたい人
この記事で到達する状態
FormとGlobalKey<FormState>で複数の入力をまとめて検証できるTextFormFieldのvalidatorで必須・形式・数値の検証を書けるautovalidateModeで入力後にだけエラーメッセージを出せる- 入力が整うまで送信ボタンを無効にする制御を書ける
非対象
今回はフォーム検証の骨組みに絞ります。扱わないテーマは次のとおりです。
- 項目をまたぐ複雑な相互検証(パスワード一致など)
flutter_form_builderやreactive_formsなどのフォーム系ライブラリ- サーバーへ問い合わせる非同期バリデーション(メール重複チェックなど)
- エラーメッセージの多言語化(
l10nは前提記事に譲り、ここは日本語直書きで示す)
2. フォーム検証の骨組み
Flutter のフォーム検証で押さえる部品は、次の 3 つです。
Form: 複数の入力欄を 1 つにまとめる入れ物。GlobalKey<FormState>で外から検証を呼べる。TextFormField: 入力欄にvalidatorを付けられるTextField。validator: 値を受け取り、問題なければnull、問題があればエラーメッセージの文字列を返す関数。
送信時は formKey.currentState!.validate() を呼ぶと、各 validator が一斉に走ります。すべて null を返せば検証通過です。入力の途中からエラーを出したい場合は autovalidateMode を使います。この記事では、検証関数を画面の外に出し、送信ボタンの可否判定にも同じ関数を使い回す形です。
3. プロジェクトを作成する
3-1. 環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create form_validation
cd form_validation
以降のコマンドとファイル操作は、このプロジェクト直下で行います。追加パッケージはありません。あとで flutter test を実行するため、DartPad ではなく実プロジェクトで進めます。
3-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
4. lib/main.dart を書き換える
flutter create が生成した lib/main.dart を、次の内容で書き換えます。検証関数 validateEmail / validateQuantity、フォーム画面、送信結果の表示までを 1 ファイルにまとめたものです。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const FormDemoApp());
}
/// メールアドレスの入力チェック。未入力と形式の 2 段で見る。
/// TextFormField の validator と送信可否の判定で同じ関数を使い回す。
String? validateEmail(String? value) {
final String text = value?.trim() ?? '';
if (text.isEmpty) {
return 'メールアドレスを入力してください';
}
if (!text.contains('@') || text.startsWith('@') || text.endsWith('@')) {
return 'メールアドレスの形式が正しくありません';
}
return null;
}
/// 数量の入力チェック。未入力・数字以外・1 未満を弾く。
String? validateQuantity(String? value) {
final String text = value?.trim() ?? '';
if (text.isEmpty) {
return '数量を入力してください';
}
final int? quantity = int.tryParse(text);
if (quantity == null) {
return '数量は数字で入力してください';
}
if (quantity < 1) {
return '数量は 1 以上にしてください';
}
return null;
}
class FormDemoApp extends StatelessWidget {
const FormDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Form Demo',
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
),
home: const OrderFormPage(),
);
}
}
class OrderFormPage extends StatefulWidget {
const OrderFormPage({super.key});
@override
State<OrderFormPage> createState() => _OrderFormPageState();
}
class _OrderFormPageState extends State<OrderFormPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _quantityController = TextEditingController();
bool _canSubmit = false;
String? _result;
@override
void dispose() {
_emailController.dispose();
_quantityController.dispose();
super.dispose();
}
// 入力のたびに、エラーを出さずに全項目の妥当性だけを見て送信可否を更新する。
void _updateSubmitState() {
final bool valid = validateEmail(_emailController.text) == null &&
validateQuantity(_quantityController.text) == null;
if (valid != _canSubmit) {
setState(() => _canSubmit = valid);
}
}
void _submit() {
if (!_formKey.currentState!.validate()) {
return;
}
setState(() {
_result = '送信しました: ${_emailController.text.trim()} / '
'数量 ${_quantityController.text.trim()}';
});
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('注文フォーム')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
onChanged: _updateSubmitState,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TextFormField(
key: const Key('email'),
controller: _emailController,
decoration: const InputDecoration(
labelText: 'メールアドレス',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: validateEmail,
),
const SizedBox(height: 16),
TextFormField(
key: const Key('quantity'),
controller: _quantityController,
decoration: const InputDecoration(
labelText: '数量',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: validateQuantity,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _canSubmit ? _submit : null,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Text('送信'),
),
),
if (_result != null) ...<Widget>[
const SizedBox(height: 24),
Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(_result!, style: theme.textTheme.titleMedium),
),
),
],
],
),
),
),
);
}
}
コードのポイント
① validator は「null なら通過」の関数にして使い回す
String? validateQuantity(String? value) {
final String text = value?.trim() ?? '';
if (text.isEmpty) {
return '数量を入力してください';
}
final int? quantity = int.tryParse(text);
if (quantity == null) {
return '数量は数字で入力してください';
}
if (quantity < 1) {
return '数量は 1 以上にしてください';
}
return null;
}
validator は値を受け取り、問題があればエラー文字列、なければ null を返します。画面の中ではなく外に出した関数にしておくと、TextFormField の validator と送信可否の判定で同じロジックを共有でき、単体テストもしやすくなります。必須・形式・範囲を上から順に見て、最初に引っかかった理由を返す形です。
② autovalidateMode で入力後にだけエラーを出す
TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: validateEmail,
// ...
)
AutovalidateMode.onUserInteraction を付けると、その欄をユーザーが触ってからエラー表示が始まります。最初の表示でいきなり「入力してください」が並ぶのを避けつつ、入力し始めたら即座にフィードバックを返せる形です。エラー文言は TextFormField が validator の戻り値を受け取り、欄の下に自動で表示します。
③ Form.onChanged で送信ボタンの可否を切り替える
void _updateSubmitState() {
final bool valid = validateEmail(_emailController.text) == null &&
validateQuantity(_quantityController.text) == null;
if (valid != _canSubmit) {
setState(() => _canSubmit = valid);
}
}
Form の onChanged は、いずれかの欄が変わるたびに呼ばれる仕組みです。そこで全項目の validator を回し、すべて null のときだけ送信ボタンを有効にします。ここで validate() を呼ばないのは、呼ぶとエラー表示まで走ってしまうためです。エラーの出し方は autovalidateMode に任せ、この関数は送信可否の判定だけに使います。ボタン側は onPressed: _canSubmit ? _submit : null とし、無効時は null で押せなくします。
④ inputFormatters で入力段階の制約をかける
TextFormField(
keyboardType: TextInputType.number,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
],
validator: validateQuantity,
)
数量欄は FilteringTextInputFormatter.digitsOnly で、そもそも数字以外を打ち込めなくします。validator による事後チェックと、inputFormatters による入力段階の制約は役割が別です。打てない文字は入口で止め、値の範囲は validator で確かめる、と分けると検証が重複しません。
5. 動かして確認する
flutter run で起動すると、両方の欄が空で、送信ボタンは押せない状態から始まります。まだ何も入力していないので、エラーメッセージは出ません。
不正な値を入れると、その欄の下にエラーメッセージが出ます。autovalidateMode を指定してあるので、送信ボタンを押す前でも、入力した時点で表示されます。
正しい値をそろえると送信ボタンが有効になります。押すと送信結果が表示される動きです。
6. テストで必須・エラー・送信可否を確かめる
lib/main.dart を書き換えたことで、flutter create 直後に入っている test/widget_test.dart は元のカウンターアプリ前提のままになり、失敗します。残したまま flutter test を実行したときの出力がこれです。
test/widget_test.dart:16:35: Error: Couldn't find constructor 'MyApp'.
await tester.pumpWidget(const MyApp());
^^^^^
生成されたテストが組み立てるのは MyApp で、書き換え後の lib/main.dart にその名前のクラスは無い状態です。テストが走る前のコンパイルで止まるため、結果は Some tests failed. だけになります。この記事ではフォームのテストに絞って進めるため、test/widget_test.dart は削除してかまいません。
検証は、検証関数の単体テストと、フォームの挙動の Widget テストの両方です。test/form_validation_test.dart を次の内容で作成します。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:form_validation/main.dart';
void main() {
group('バリデータ単体', () {
test('メールは未入力と形式不正を弾く', () {
expect(validateEmail(''), isNotNull);
expect(validateEmail('invalid'), isNotNull);
expect(validateEmail('user@example.com'), isNull);
});
test('数量は未入力・非数値・1未満を弾く', () {
expect(validateQuantity(''), isNotNull);
expect(validateQuantity('abc'), isNotNull);
expect(validateQuantity('0'), isNotNull);
expect(validateQuantity('3'), isNull);
});
});
group('フォームの挙動', () {
testWidgets('必須未入力だと送信ボタンが押せない', (WidgetTester tester) async {
await tester.pumpWidget(const FormDemoApp());
final Finder submit = find.widgetWithText(FilledButton, '送信');
expect(tester.widget<FilledButton>(submit).onPressed, isNull);
});
testWidgets('不正な入力でエラーメッセージを表示する', (WidgetTester tester) async {
await tester.pumpWidget(const FormDemoApp());
await tester.enterText(find.byKey(const Key('email')), 'invalid');
await tester.enterText(find.byKey(const Key('quantity')), '0');
await tester.pump();
expect(find.text('メールアドレスの形式が正しくありません'), findsOneWidget);
expect(find.text('数量は 1 以上にしてください'), findsOneWidget);
});
testWidgets('正しく入力すると送信できる', (WidgetTester tester) async {
await tester.pumpWidget(const FormDemoApp());
await tester.enterText(find.byKey(const Key('email')), 'user@example.com');
await tester.enterText(find.byKey(const Key('quantity')), '3');
await tester.pump();
final Finder submit = find.widgetWithText(FilledButton, '送信');
expect(tester.widget<FilledButton>(submit).onPressed, isNotNull);
await tester.tap(submit);
await tester.pump();
expect(find.textContaining('送信しました'), findsOneWidget);
});
});
}
flutter test を実行すると、5 件とも通ります。
00:00 +0: バリデータ単体 メールは未入力と形式不正を弾く
00:00 +1: バリデータ単体 数量は未入力・非数値・1未満を弾く
00:00 +2: フォームの挙動 必須未入力だと送信ボタンが押せない
00:00 +3: フォームの挙動 不正な入力でエラーメッセージを表示する
00:00 +4: フォームの挙動 正しく入力すると送信できる
00:01 +5: All tests passed!
検証関数を外に出したので、単体テストでは画面を立ち上げずに条件を網羅できる形です。フォーム全体の振る舞いは Widget テストで enterText と tap を使って確かめます。送信ボタンの可否は onPressed が null かどうかで判定できるため、「入力が整うまで押させない」という制御もテストで固定できます。
7. まとめ
フォーム検証は、Form / TextFormField / validator の 3 点で組み立てます。
validatorは「nullなら通過」の関数にして、欄の検証と送信可否で共有するautovalidateMode.onUserInteractionで、触ってからエラーを出すForm.onChangedで送信ボタンの可否を更新し、整うまで押させない
まずは 1 画面のフォームを TextField から TextFormField へ置き換え、validator を 1 つ足すところから始めてください。エラーメッセージを多言語化するなら Flutterのl10n・ARBで多言語対応を最小構成で実装する へ、フォームの挙動をテストで固めるなら FlutterのWidgetテスト入門(最初の1本) へつなぐと、入力画面の土台が固まります。