forms-and-input について
フォームのフィールド、検証、ViewModel の責務を分けて設計する Flutter 開発スキルです。同期・デバウンス付き非同期検証、フォーカス移動、キーボード動作、再ビルド範囲を扱います。
主な機能
- 01Controller と FocusNode のライフサイクル管理
- 02ローカライズされた同期検証
- 03Notifier でのデバウンス付き非同期検証
- 04有効性から送信状態を導出
- 05フィールドごとのキーボードと自動入力設定
- 06再ビルド範囲とエラーのセマンティクス
活用シーン
- 01ローカライズされた検証メッセージを持つフォームの構築
- 02入力を止めずにリモートの利用可否を確認
- 03フォーカス移動とキーボード操作の管理
- 04入力中の再ビルドを抑制
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill forms-and-inputインストール後、Claude Code で/forms-and-inputを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/async_field_notifier.darttask_form.dartreferences/focus-and-keyboard.mdvalidation-sync-and-async.mdscripts/check_forms.sh詳細
フォームと入力
テキスト入力は、破棄漏れ、未ローカライズの文字列、カクつきが Flutter アプリに入り込む原因の多くを占めます。フォームは小さな状態機械です。フィールドがテキストを保持し、FormState が検証し、ViewModel がネットワークや時計に関わる処理を管理します。この3つの責務は分離してください。
作業内容に応じて、以下のリファレンスを確認してください。
references/validation-sync-and-async.md— 同期validatorがローカライズ済みのString?を返す方法、AutovalidateModeの選び方、デバウンス付き非同期処理を Notifier で行うパターン(非同期処理を同期バリデーターに含めてはいけない理由)。references/focus-and-keyboard.md—FocusNodeのライフサイクル、フォーカス移動順序、autofocus、FocusTraversalGroup、TextInputAction、onFieldSubmitted/onEditingComplete、keyboardType、autofillHints、TextInputFormatter、キーボード表示時のレイアウト調整。
PR の前に scripts/check_forms.sh を実行してください。
必須ルール
State内で作成したすべてのTextEditingControllerとFocusNodeは、dispose()で破棄してください。 これらはネイティブリソースとリスナーを保持します。リークすると、ウィジェットが破棄された後も残り、破棄済みのツリーに対してコールバックが実行されます。値をウィジェットより長く保持する必要がある場合は、代わりに Notifier で管理してください。state-management-riverpodを参照してください。- バリデーターのメッセージは必ずローカライズし、ハードコードしないでください。
validatorは'Required'ではなく、AppLocalizations.of(context).fieldRequiredを返します。エラーテキストはユーザー向け UI の文言であり、i18n-rtl-l10nが管理します。check_forms.shは、バリデーターから返される文字列リテラルを grep で検出すると失敗します。 - 同期
validatorは純粋かつ即時に処理し、await、ネットワークアクセス、Futureを使わないでください。FormFieldValidator<T>はString? Function(T?)です。非同期にはできず、Flutter はレイアウト中に同期的に呼び出します。利用可能性や一意性のチェックは Notifier で行います(ルール4)。 - 非同期バリデーションはデバウンス付きの Riverpod Notifier で行い、状態を通じて結果を反映してください。
dart:asyncのTimerでデバウンスし、テストでは時計ではなくfakeAsyncを使って動作を決定的にします。その後チェックを実行し、フィールドがInputDecoration.errorText経由で参照するAsyncValueまたはシールド型の状態を公開します。チェックで記録するタイムスタンプは、必ずref.read(clockProvider).now()から取得し、DateTime.now()は使いません。Clock の境界はservice-boundary-and-nativeが管理します。キー入力を I/O 待ちでブロックしてはいけません。破棄時のキャンセルについてはasync-safetyを参照してください。 - 送信可能かどうかは有効性から導出し、別の
boolとして保存してはいけません。 保存した_isValidフラグは、フィールドの状態とずれてしまいます。ビルド時にFormState/Notifier の状態から計算してください。導出して保存しない方法についてはflutter-performanceを参照してください。 - キー入力による再ビルドはフォーム全体ではなく、1つのフィールドだけにしてください。 各フィールドに専用のコントローラー/
FormFieldを用意し、生テキストをトップレベルのsetState/watchに引き上げて、兄弟フィールドすべてを再ビルドしないでください。小さなウィジェットとref.watch(....select(...))を使って再ビルド範囲を限定してください。widget-compositionとflutter-performanceを参照してください。 AutovalidateModeは意図を持って選択してください。 デフォルトはAutovalidateMode.onUserInteractionです。ユーザーがフィールドに触れるまでは何も表示せず、その後はリアルタイムで検証します。ユーザーが入力する前からエラーを表示するalwaysは使わないでください。フィールドごとのフィードバックが煩雑になる短いフォームに限り、送信時のみ検証してください。- キーボードの種類、大文字・小文字の設定、自動入力はフィールドごとに指定してください。
keyboardType、textCapitalization、autofillHints、TextInputFormatterは装飾ではなく、入力の仕様です。autofillHintsがないと、OS の自動入力やパスワードマネージャーが機能しません。 - エラーは色を変えるだけでなく、読み上げられるようにしてください。
InputDecoration.labelText/errorTextはセマンティクスを備えており、スクリーンリーダーが変更時に読み上げます。色だけでエラーを示してはいけません。accessibility-as-codeを参照してください。
フォームの基本構成
Form と GlobalKey<FormState> を組み合わせて、各フィールドをまとめて管理します。このキーを使うと、送信ハンドラーからすべてのフィールドに対して validate()/save() を一度に呼び出せます。
class TaskForm extends StatefulWidget {
const TaskForm({super.key, required this.onSubmit});
final void Function(String title) onSubmit;
@override
State<TaskForm> createState() => _TaskFormState();
}
class _TaskFormState extends State<TaskForm> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _titleFocus = FocusNode();
@override
void dispose() {
_titleController.dispose(); // rule 1: always dispose
_titleFocus.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
widget.onSubmit(_titleController.text.trim());
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // rule 7
child: Column(
children: [
TextFormField(
controller: _titleController,
focusNode: _titleFocus,
autofocus: true,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.taskTitleLabel),
validator: (value) => // rule 2 + 3: localized, pure
(value == null || value.trim().isEmpty) ? l10n.fieldRequired : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
);
}
}
同期バリデーション
validator は、フィールドの値を受け取る全域的な同期関数です。有効な場合は null、それ以外の場合はローカライズ済みのメッセージを返します。小さなチェックを組み合わせ、クロージャーは短く保ってください。
String? validateTitle(String? value, AppLocalizations l10n) {
final text = value?.trim() ?? '';
if (text.isEmpty) return l10n.fieldRequired;
if (text.length > 120) return l10n.fieldTooLong; // structural bound, not design
return null;
}
非同期バリデーション(バリデーターの外で実行)
アカウント名が使用済みかどうかの確認は、I/O 処理です。Notifier 内で実行し、clockProvider に基づいてデバウンスします。フィールドは errorText を通じて結果を読み取ります。同期の validator は純粋なままにし、入力形式の検証だけを行います。パターン全体については、references/validation-sync-and-async.md と examples/async_field_notifier.dart を参照してください。
// The field is driven by Notifier state, not by an async validator.
final status = ref.watch(nameAvailabilityNotifierProvider);
TextFormField(
controller: _nameController,
onChanged: ref.read(nameAvailabilityNotifierProvider.notifier).onNameChanged,
decoration: InputDecoration(
labelText: l10n.accountNameLabel,
errorText: switch (status) {
AsyncData(:final value) when value == NameCheck.taken => l10n.nameTaken,
AsyncError() => l10n.nameCheckFailed,
_ => null, // idle / loading / available: no error
},
),
);
フォーカスとキーボード操作の流れ
TextInputAction.next で次のフィールドに移動し、.done で送信します。onFieldSubmitted で FocusScope.of(context).nextFocus() を呼び出すか、特定のノードにフォーカスを要求してフォーカスを移動します。関連するフィールドは FocusTraversalGroup でまとめ、Tab キーの移動順序を制御します。詳細は references/focus-and-keyboard.md を参照してください。
TextFormField(
focusNode: _titleFocus,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),
有効性に基づいて送信ボタンを制御
_isFormValid のような bool を保存しないでください。ビルドのたびに有効化の状態を導出し、非同期送信の実行中は(Notifier の状態に基づいて)無効にします。
final submitting = ref.watch(taskFormNotifierProvider).isLoading;
FilledButton(
onPressed: submitting ? null : _submit, // rule 5
child: Text(l10n.saveAction),
);
キーボードによる画面の回避
長いフォームは、フォーカス中のフィールドがキーボードの上にスクロールされるようにラップします。body 内に SingleChildScrollView を配置すると、Scaffold が(デフォルトの resizeToAvoidBottomInset: true により)コンテンツを押し上げます。最後のフィールドで送信する場合は、送信ボタンにアクセスできるようにします。ボタンをスクロールビュー内または bottomNavigationBar に配置してください。
アンチパターン
validator: (v) async => await repo.isTaken(v)— バリデーターは非同期にできません。Futureは truthy なので、常に「合格」になります。Notifier に移してください(ルール 4)。- バリデーターから
'Required'/'Invalid email'を返す — ローカライズされておらず、英語以外のすべてのロケールで問題になります。AppLocalizationsを使用してください。 build()内でTextEditingController/FocusNodeを作成する — 再ビルドのたびに新しいものが作成され、テキストとカーソル位置が失われます。State内で作成し、dispose()で破棄してください。onChangedでbool _isValidを切り替えて送信を有効にする — 実際の有効性とずれていきます。有効性は導出してください。autovalidateMode: AutovalidateMode.always— ユーザーが1文字も入力する前からエラーが表示されます。setStateを呼び出すTextEditingControllerのリスナーを1つ作り、フォーム全体を更新する — キー入力のたびにすべてのフィールドが再ビルドされます。再ビルドの範囲を限定してください。DateTime.now()の差分を使って手作業でdebounceのタイミングを管理する —dart:asyncのTimerを使用してください(fakeAsyncで決定的にテストできます)。また、チェックで記録するタイムスタンプは、必ずref.read(clockProvider).now()から取得し、DateTime.now()は使わないでください。
完了の定義
- すべてのコントローラー/
FocusNodeが破棄されている(または状態が Notifier に保持されている)。check_forms.shが問題なく完了する。 - いずれの
validatorからも文字列リテラルを返さず、すべてのメッセージがAppLocalizations経由である。 - 同期
validator内にawait/Futureがなく、非同期チェックはデバウンスする Notifier 内で行い、errorTextを通じて反映されている。 AutovalidateMode.onUserInteractionを使用している(または、意図的に送信時のみ検証する設定になっている)。- 送信の有効化状態は保存せずに導出し、送信の実行中は無効になっている。
- 各フィールドで、該当する場合は
keyboardType、textInputAction、autofillHintsを宣言し、フォーカスが正しく移動する。 - エラーは
InputDecorationのセマンティクスを通じて通知し、色だけに頼らない。
関連スキル
state-management-riverpod— 非同期バリデーションと送信状態を管理する Notifier。async-safety— 破棄時にデバウンスタイマー/サブスクリプションをキャンセルする。await 後に mounted ガードを使用する。i18n-rtl-l10n— ローカライズされたバリデータメッセージとラベル、および null にならないAppLocalizations.of(context)ゲッター。accessibility-as-code— フィールドラベル、エラー通知、色だけに頼らないこと、ターゲットサイズ。flutter-performance— 範囲を限定した再ビルドと、保存せずに導出する方法。widget-composition—_buildFieldメソッドではなく、小さな const フィールドウィジェット。navigation-and-routing— 編集済みのフォームから離れる際に未保存の変更を確認するPopScope。service-boundary-and-native— 非同期チェックでタイムスタンプを取得する Clock の境界(clockProvider)。
References
- Form: https://api.flutter.dev/flutter/widgets/Form-class.html
- TextFormField: https://api.flutter.dev/flutter/material/TextFormField-class.html
- FocusNode: https://api.flutter.dev/flutter/widgets/FocusNode-class.html
- TextInputAction: https://api.flutter.dev/flutter/services/TextInputAction.html
- Autofill: https://api.flutter.dev/flutter/services/AutofillHints-class.html
- Forms cookbook: https://docs.flutter.dev/cookbook/forms/validation