公開日 2026-08-30

Flutterでフォーム入力とバリデーションを組む(Form・TextFormField最小構成)

FormとTextFormFieldで必須・形式・数値の検証とエラー表示を組み、入力の妥当性で送信ボタンの可否を切り替える最小構成をFlutterで作る。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. フォーム検証の骨組み
  6. 3. プロジェクトを作成する
  7. 3-1. 環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 4. lib/main.dart を書き換える
  11. コードのポイント
  12. 5. 動かして確認する
  13. 6. テストで必須・エラー・送信可否を確かめる
  14. 7. まとめ

検索条件、伝票入力、ログインフォームなど、業務アプリの画面は入力フォームの上に積み上がります。土台になるのが、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 を指定してあるので、送信ボタンを押す前でも、入力した時点で表示されます。

メールアドレスに invalid、数量に 0 を入れた状態。それぞれの欄の下に「メールアドレスの形式が正しくありません」「数量は 1 以上にしてください」が赤字で表示され、送信ボタンは無効のまま

正しい値をそろえると送信ボタンが有効になります。押すと送信結果が表示される動きです。

メールアドレスに user@example.com、数量に 3 を入れた状態。エラー表示は消え、送信ボタンが有効になっている

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本) へつなぐと、入力画面の土台が固まります。

シリーズ 41/42

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)
  39. 39. Flutterの時計注入を用途で分ける:業務時刻とクールダウンを混ぜない
  40. 40. Flutterでdioとinterceptorでトークンリフレッシュを実装する(401で自動更新・再試行)
  41. 41. Flutterでフォーム入力とバリデーションを組む(Form・TextFormField最小構成) 現在の記事
  42. 42. Flutterで一覧検索とページングを実装する(業務検索UIの最小構成)