Flutter: Form and Input

Forms are the bridge between users and your app — lax validation lets garbage data in; poor UX drives users away.

📋 Prerequisites: You need to master the following first

1. What You Will Learn


2. A True Story of a Payment Form Gone Wrong

(1) Pain Point: Invalid Data Causes Payment Failure

Bob enters his credit card number on the ShopApp checkout page but misses a digit. The system doesn't warn him and submits directly — the bank rejects the payment. He re-enters with an extra space, and it fails again. The coupon code is even worse — he enters "SAVE20" but the actual code is "save20"; the case mismatch triggers an error. After each payment failure, Bob has to re-fill every field.

(2) The Form Validation Solution

Flutter's Form + validator mechanism intercepts all invalid data before submission, TextInputFormatter formats input in real time, and onSaved collects everything uniformly.

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
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

(3) Benefit: Zero Invalid Submissions

After switching to Form validation, Bob gets real-time credit card format checking, coupon codes auto-converted to uppercase, and payment success rate improves from 70% to 98%.


3. The Form System

(1) Core Form Components

Widget Responsibility Key Property
Form Form container, manages child FormFields key: GlobalKey<FormState>
FormField<T> Abstract field, manages validation/saving validator, onSaved
TextFormField Text input FormField controller, decoration, inputFormatters

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

: Basic form structure

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
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

4. Validation Mechanism

(1) AutovalidateMode

Mode Trigger Timing Use Case
disabled Only on manual validate() call Default, validate on submit
always Every input change Real-time feedback
onUserInteraction After first user interaction Balance UX and interruption

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

: Credit card number validation

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
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

: Async validation (coupon code)

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
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

5. TextInputFormatter Format Control

(1) Common Formatters

Formatter Purpose Example Input → Output
FilteringTextInputFormatter.digitsOnly Digits only "abc123" → "123"
FilteringTextInputFormatter.allow(RegExp) Allow matching pattern Custom regex
FilteringTextInputFormatter.deny(RegExp) Deny matching pattern Custom regex
Custom Formatter Any formatting Credit card/currency/phone

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

: Credit card number formatter

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
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

: Currency input formatter

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
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

6. Form State Management

▶ Example

TEXT
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

: Form save and reset

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
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

7. Complete Example: ShopApp Checkout Page

⚙️ Install dependency: 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
> Output: Run on a local Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed — use `flutter run` on your local machine for hands-on comparison. Actual UI/state may vary slightly by platform.

❓ FAQ

Q Why use GlobalKey<FormState> as a global key?
A FormState's validate/save/reset methods need to be called from outside the widget. GlobalKey allows cross-widget access to the Form's state.
Q How to use async validators?
A The validator returns Future<String?>, natively supported in Flutter 3.x. During async validation, the field shows a loading indicator.
Q When does TextInputFormatter's formatEditUpdate trigger?
A It triggers on every character input, before the validator runs. Ideal for real-time formatting (e.g., adding spaces to card numbers).
Q How to persist form data (survive screen rotation)?
A Use TextEditingController to hold data, save to SharedPreferences in dispose. Or use Riverpod to manage form state.
Q What's the difference between validator returning null vs an empty string?
A null means validation passed; any non-null string is displayed as an error. Returning an empty string shows a blank error area.
Q How to validate fields that depend on each other?
A Access other field values via setState or controller within the validator. For example, a password confirmation field reads the password field value for comparison.

📖 Summary


📝 Exercises

  1. Basic (⭐): Create a login form with email and password fields — validate email format and password length ≥ 8.
  2. Intermediate (⭐⭐): Implement credit card number input: auto-add spaces every 4 digits, real-time Luhn algorithm validation, MM/YY date formatting.
  3. Challenge (⭐⭐⭐): Implement a complete checkout form: shipping address + credit card + coupon code async validation + automatic discounted total calculation.

← Previous Lesson | Next Lesson →

Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏