mcpField
F
forms-and-input

forms-and-input

github.com/zakariaf開発63154 回インストール5 週間前に更新
リポジトリ

forms-and-input について

フォームのフィールド、検証、ViewModel の責務を分けて設計する Flutter 開発スキルです。同期・デバウンス付き非同期検証、フォーカス移動、キーボード動作、再ビルド範囲を扱います。

主な機能

  1. 01Controller と FocusNode のライフサイクル管理
  2. 02ローカライズされた同期検証
  3. 03Notifier でのデバウンス付き非同期検証
  4. 04有効性から送信状態を導出
  5. 05フィールドごとのキーボードと自動入力設定
  6. 06再ビルド範囲とエラーのセマンティクス

活用シーン

  1. 01ローカライズされた検証メッセージを持つフォームの構築
  2. 02入力を止めずにリモートの利用可否を確認
  3. 03フォーカス移動とキーボード操作の管理
  4. 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.dart
task_form.dart
references/
focus-and-keyboard.md
validation-sync-and-async.md
scripts/
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 を実行してください。

必須ルール

  1. State 内で作成したすべての TextEditingController と FocusNode は、dispose() で破棄してください。 これらはネイティブリソースとリスナーを保持します。リークすると、ウィジェットが破棄された後も残り、破棄済みのツリーに対してコールバックが実行されます。値をウィジェットより長く保持する必要がある場合は、代わりに Notifier で管理してください。state-management-riverpod を参照してください。
  2. バリデーターのメッセージは必ずローカライズし、ハードコードしないでください。 validator は 'Required' ではなく、AppLocalizations.of(context).fieldRequired を返します。エラーテキストはユーザー向け UI の文言であり、i18n-rtl-l10n が管理します。check_forms.sh は、バリデーターから返される文字列リテラルを grep で検出すると失敗します。
  3. 同期 validator は純粋かつ即時に処理し、await、ネットワークアクセス、Future を使わないでください。 FormFieldValidator<T> は String? Function(T?) です。非同期にはできず、Flutter はレイアウト中に同期的に呼び出します。利用可能性や一意性のチェックは Notifier で行います(ルール4)。
  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 を参照してください。
  5. 送信可能かどうかは有効性から導出し、別の bool として保存してはいけません。 保存した _isValid フラグは、フィールドの状態とずれてしまいます。ビルド時に FormState/Notifier の状態から計算してください。導出して保存しない方法については flutter-performance を参照してください。
  6. キー入力による再ビルドはフォーム全体ではなく、1つのフィールドだけにしてください。 各フィールドに専用のコントローラー/FormField を用意し、生テキストをトップレベルの setState/watch に引き上げて、兄弟フィールドすべてを再ビルドしないでください。小さなウィジェットと ref.watch(....select(...)) を使って再ビルド範囲を限定してください。widget-composition と flutter-performance を参照してください。
  7. AutovalidateMode は意図を持って選択してください。 デフォルトは AutovalidateMode.onUserInteraction です。ユーザーがフィールドに触れるまでは何も表示せず、その後はリアルタイムで検証します。ユーザーが入力する前からエラーを表示する always は使わないでください。フィールドごとのフィードバックが煩雑になる短いフォームに限り、送信時のみ検証してください。
  8. キーボードの種類、大文字・小文字の設定、自動入力はフィールドごとに指定してください。 keyboardType、textCapitalization、autofillHints、TextInputFormatter は装飾ではなく、入力の仕様です。autofillHints がないと、OS の自動入力やパスワードマネージャーが機能しません。
  9. エラーは色を変えるだけでなく、読み上げられるようにしてください。 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

コメント