Flutter: フォームと入力

フォームはユーザーとアプリの架け橋です — 甘いバリデーションはゴミデータを流入させ、悪いUXはユーザーを遠ざけます。

📋 前提条件: 以下を先にマスターしている必要があります

1. このレッスンで学ぶこと


2. 支払いフォームの失敗に関するリアルなストーリー

(1) 悩み:無効なデータによる決済失敗

BobはShopAppのチェックアウトページでクレジットカード番号を入力しましたが、1桁足りません。システムは警告せず、そのまま送信 — 銀行が決済を拒否しました。スペースを追加して再入力すると、また失敗。クーポンコードはさらにひどく — 「SAVE20」と入力しましたが、実際のコードは「save20」で、大文字小文字の不一致でエラーに。決済が失敗するたびに、Bobはすべてのフィールドを再入力しなければなりません。

(2) フォームバリデーションのソリューション

FlutterのForm + validatorメカニズムは、すべての無効なデータを送信前にブロックし、TextInputFormatterがリアルタイムで入力をフォーマットし、onSavedがすべてを一括収集します。

DART
import 'package:flutter/material.dart';

// Validate entire form before submission
if (_formKey.currentState!.validate()) {
  _formKey.currentState!.save();
  // All fields valid, proceed to payment
  processPayment();
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

(3) メリット:無効な送信ゼロ

フォームバリデーションに切り替えた後、Bobはリアルタイムのクレジットカードフォーマットチェックを得て、クーポンコードは自動で大文字に変換され、決済成功率が70%から98%に向上しました。


3. フォームシステム

(1) コアフォームコンポーネント

ウィジェット 責務 キープロパティ
Form フォームコンテナ、子FormFieldを管理 key: GlobalKey<FormState>
FormField<T> 抽象フィールド、バリデーション/保存を管理 validator, onSaved
TextFormField テキスト入力FormField controller, decoration, inputFormatters

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: 基本的なフォーム構造

DART
import 'package:flutter/material.dart';

class CheckoutForm extends StatefulWidget {
  const CheckoutForm({super.key});

  @override
  State<CheckoutForm> createState() => _CheckoutFormState();
}

class _CheckoutFormState extends State<CheckoutForm> {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: const InputDecoration(labelText: 'Full Name'),
            validator: (value) {
              if (value == null || value.isEmpty) return 'Name is required';
              if (value.length < 2) return 'Name too short';
              return null;
            },
            onSaved: (value) => _name = value!,
          ),
          TextFormField(
            decoration: const InputDecoration(labelText: 'Email'),
            validator: (value) {
              if (value == null || !value.contains('@')) return 'Invalid email';
              return null;
            },
          ),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('Submit Order'),
          ),
        ],
      ),
    );
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save();
      // Process order
    }
  }
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

4. バリデーションの仕組み

(1) AutovalidateMode

モード トリガーのタイミング 用途
disabled 手動validate()呼び出し時のみ デフォルト、送信時にバリデーション
always 入力のたびに毎回 リアルタイムフィードバック
onUserInteraction 最初のユーザー操作後 UXと中断のバランス

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: クレジットカード番号のバリデーション

DART
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

TextFormField(
  decoration: const InputDecoration(
    labelText: 'Card Number',
    hintText: '1234 5678 9012 3456',
    prefixIcon: Icon(Icons.credit_card),
  ),
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    CardNumberFormatter(),
  ],
  validator: (value) {
    if (value == null || value.isEmpty) return 'Card number is required';
    final digits = value.replaceAll(' ', '');
    if (digits.length != 16) return 'Card number must be 16 digits';
    if (!_luhnCheck(digits)) return 'Invalid card number';
    return null;
  },
  onSaved: (value) => _cardNumber = value!.replaceAll(' ', ''),
)

// Luhn algorithm for card validation
bool _luhnCheck(String digits) {
  int sum = 0;
  for (int i = 0; i < digits.length; i++) {
    int d = int.parse(digits[digits.length - 1 - i]);
    if (i % 2 == 1) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
  }
  return sum % 10 == 0;
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: 非同期バリデーション(クーポンコード)

DART
import 'package:flutter/material.dart';

// Simplified class definition
class CouponService {
  static Future<bool> validate(String code) async => code == 'SAVE20';
}

TextFormField(
  decoration: const InputDecoration(labelText: 'Coupon Code'),
  autovalidateMode: AutovalidateMode.onUserInteraction,
  validator: (value) async {
    if (value == null || value.isEmpty) return null;
    if (value.length < 4) return 'Code too short';
    final isValid = await CouponService.validate(value.toUpperCase());
    if (!isValid) return 'Invalid coupon code';
    return null;
  },
)
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

5. TextInputFormatterフォーマット制御

(1) 一般的なフォーマッタ

フォーマッタ 目的 入力例 → 出力例
FilteringTextInputFormatter.digitsOnly 数字のみ "abc123" → "123"
FilteringTextInputFormatter.allow(RegExp) マッチパターンを許可 カスタム正規表現
FilteringTextInputFormatter.deny(RegExp) マッチパターンを拒否 カスタム正規表現
カスタムフォーマッタ 任意のフォーマット クレジットカード/通貨/電話番号

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: クレジットカード番号フォーマッタ

DART
import 'package:flutter/services.dart';

class CardNumberFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final digits = newValue.text.replaceAll(' ', '');
    final buffer = StringBuffer();
    for (int i = 0; i < digits.length && i < 16; i++) {
      if (i > 0 && i % 4 == 0) buffer.write(' ');
      buffer.write(digits[i]);
    }
    final formatted = buffer.toString();
    return TextEditingValue(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: 通貨入力フォーマッタ

DART
import 'package:flutter/services.dart';

class CurrencyFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // Only allow digits and one decimal point
    final text = newValue.text.replaceAll(RegExp(r'[^\d.]'), '');
    final parts = text.split('.');
    if (parts.length > 2) return oldValue;
    if (parts.length == 2 && parts[1].length > 2) return oldValue;
    return TextEditingValue(
      text: text,
      selection: TextSelection.collapsed(offset: text.length),
    );
  }
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

6. フォーム状態管理

▶ サンプル

TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

: フォームの保存とリセット

DART
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// CardNumberFormatter class definition see above

class _CheckoutFormState extends State<CheckoutForm> {
  final _formKey = GlobalKey<FormState>();
  String _name = '';
  String _address = '';
  String _cardNumber = '';

  void _submit() {
    final form = _formKey.currentState!;
    if (form.validate()) {
      form.save(); // Triggers all onSaved callbacks
      processOrder(name: _name, address: _address, card: _cardNumber);
    }
  }

  void _reset() {
    _formKey.currentState!.reset(); // Clears all fields and errors
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(children: [
        TextFormField(
          decoration: const InputDecoration(labelText: 'Full Name'),
          onSaved: (v) => _name = v ?? '',
        ),
        TextFormField(
          decoration: const InputDecoration(labelText: 'Shipping Address'),
          onSaved: (v) => _address = v ?? '',
        ),
        TextFormField(
          decoration: const InputDecoration(labelText: 'Card Number'),
          inputFormatters: [FilteringTextInputFormatter.digitsOnly, CardNumberFormatter()],
          onSaved: (v) => _cardNumber = v?.replaceAll(' ', '') ?? '',
        ),
        const SizedBox(height: 20),
        Row(children: [
          Expanded(child: OutlinedButton(onPressed: _reset, child: const Text('Reset'))),
          const SizedBox(width: 12),
          Expanded(child: FilledButton(onPressed: _submit, child: const Text('Pay Now'))),
        ]),
      ]),
    );
  }
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

7. 完成例:ShopAppチェックアウトページ

⚙️ 依存パッケージのインストール: flutter pub add go_router

DART
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';

// CardNumberFormatter class definition see above

// Simplified class definition
class ExpiryDateFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
    final text = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
    final buffer = StringBuffer();
    for (int i = 0; i < text.length && i < 4; i++) {
      if (i == 2) buffer.write('/');
      buffer.write(text[i]);
    }
    final formatted = buffer.toString();
    return TextEditingValue(text: formatted, selection: TextSelection.collapsed(offset: formatted.length));
  }
}

class CouponService {
  static Future<double> validateAndGetDiscount(String code) async {
    await Future.delayed(const Duration(milliseconds: 500));
    if (code == 'SAVE20') return 0.2;
    return 0;
  }
}

class CheckoutPage extends StatefulWidget {
  final double total;
  const CheckoutPage({super.key, required this.total});

  @override
  State<CheckoutPage> createState() => _CheckoutPageState();
}

class _CheckoutPageState extends State<CheckoutPage> {
  final _formKey = GlobalKey<FormState>();
  final _couponController = TextEditingController();
  String? _couponError;
  double _discount = 0;

  void _applyCoupon() async {
    final code = _couponController.text.toUpperCase();
    if (code.length < 4) {
      setState(() => _couponError = 'Code too short');
      return;
    }
    final discount = await CouponService.validateAndGetDiscount(code);
    if (discount > 0) {
      setState(() { _discount = discount; _couponError = null; });
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('${(discount * 100).toStringAsFixed(0)}% discount applied!')),
      );
    } else {
      setState(() => _couponError = 'Invalid coupon code');
    }
  }

  void _submitOrder() {
    if (!_formKey.currentState!.validate()) return;
    _formKey.currentState!.save();
    showDialog(context: context, builder: (_) => AlertDialog(
      title: const Text('Order Confirmed'),
      content: Text('Total: \$${(widget.total * (1 - _discount)).toStringAsFixed(2)}'),
      actions: [FilledButton(onPressed: () => context.go('/'), child: const Text('OK'))],
    ));
  }

  @override
  Widget build(BuildContext context) {
    final finalTotal = widget.total * (1 - _discount);
    return Scaffold(
      appBar: AppBar(title: const Text('Checkout')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Form(key: _formKey, child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Shipping Address', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Full Name', prefixIcon: Icon(Icons.person)),
              validator: (v) => v?.isEmpty ?? true ? 'Required' : null, onSaved: (v) {}),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Address Line 1', prefixIcon: Icon(Icons.home)),
              validator: (v) => v?.isEmpty ?? true ? 'Required' : null, onSaved: (v) {}),
            const SizedBox(height: 12),
            Row(children: [
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'City'),
                validator: (v) => v?.isEmpty ?? true ? 'Required' : null)),
              const SizedBox(width: 12),
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'Zip Code'),
                keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly])),
            ]),
            const Divider(height: 32),
            const Text('Payment', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Card Number', prefixIcon: Icon(Icons.credit_card)),
              keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly, CardNumberFormatter()],
              validator: (v) => (v?.replaceAll(' ', '').length ?? 0) != 16 ? 'Invalid card number' : null),
            const SizedBox(height: 12),
            Row(children: [
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'MM/YY'),
                keyboardType: TextInputType.number, inputFormatters: [ExpiryDateFormatter()],
                validator: (v) => v?.length != 5 ? 'Invalid' : null)),
              const SizedBox(width: 12),
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'CVV'),
                keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(3)],
                obscureText: true, validator: (v) => v?.length != 3 ? 'Invalid' : null)),
            ]),
            const Divider(height: 32),
            TextFormField(controller: _couponController, decoration: InputDecoration(
              labelText: 'Coupon Code', errorText: _couponError,
              suffixIcon: TextButton(onPressed: _applyCoupon, child: const Text('Apply'))),
            ),
            const SizedBox(height: 24),
            Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(12)),
              child: Column(children: [
                Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  const Text('Subtotal'), Text('\$${widget.total.toStringAsFixed(2)}')]),
                if (_discount > 0) Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  Text('Discount (${(_discount * 100).toStringAsFixed(0)}%)'),
                  Text('-\$${(widget.total * _discount).toStringAsFixed(2)}', style: const TextStyle(color: Colors.green))]),
                const Divider(),
                Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
                  Text('\$${finalTotal.toStringAsFixed(2)}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold))]),
              ])),
            const SizedBox(height: 16),
            SizedBox(width: double.infinity, child: FilledButton.icon(
              onPressed: _submitOrder, icon: const Icon(Icons.lock), label: const Text('Pay \$${finalTotal.toStringAsFixed(2)}'),
              style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)))),
          ],
        )),
      ),
    );
  }
}
TEXT
> 出力: Flutter SDK(Flutter 3.x / Dart 3.x)でローカルに実行してください。PistonサーバーにはFlutterがインストールされていません。ローカルマシンで`flutter run`を使用して比較してください。実際のUI/状態はプラットフォームにより多少異なる場合があります。

❓ よくある質問

Q なぜGlobalKey<FormState>をグローバルキーとして使うのですか?
A FormStateのvalidate/save/resetメソッドはウィジェット外から呼び出す必要があります。GlobalKeyはウィジェット間でFormの状態にアクセスすることを許可します。
Q 非同期バリデーターの使い方は?
A validatorがFuture<String?>を返す形式で、Flutter 3.xでネイティブサポートされています。非同期バリデーション中はフィールドにローディングインジケーターが表示されます。
Q TextInputFormatterのformatEditUpdateはいつトリガーされますか?
A 文字入力のたびにトリガーされ、validatorより前に実行されます。リアルタイムフォーマット(カード番号へのスペース追加など)に最適です。
Q フォームデータを永続化するには(画面回転でも維持)?
A TextEditingControllerでデータを保持し、disposeでSharedPreferencesに保存します。またはRiverpodでフォーム状態を管理します。
Q validatorがnullを返す場合と空文字列を返す場合の違いは?
A nullはバリデーション通過を意味します。null以外の文字列はすべてエラーとして表示されます。空文字列を返すと空白のエラー領域が表示されます。
Q 互いに依存するフィールドをバリデーションするには?
A validator内でsetStateやコントローラーを通じて他のフィールドの値にアクセスします。例えば、パスワード確認フィールドがパスワードフィールドの値を読み取って比較します。

📖 まとめ


📝 練習問題

  1. 基本(⭐): メールとパスワードフィールドを持つログインフォームを作成してください — メールのフォーマットとパスワード長≥ 8をバリデーション。
  2. 中級(⭐⭐): クレジットカード番号入力を実装してください:4桁ごとに自動でスペース追加、リアルタイムLuhnアルゴリズムバリデーション、MM/YY日付フォーマット。
  3. チャレンジ(⭐⭐⭐): 完全なチェックアウトフォームを実装してください:配送先住所 + クレジットカード + クーポンコードの非同期バリデーション + 割引適用後の自動合計計算。

← 前へ | 次へ →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%