Flutter: 表单与输入

表单是用户与应用的桥梁——验证不严,垃圾数据涌入;体验不佳,用户弃单而去。

📋 前置知识:需要先掌握以下内容

1. 你将学到


2. 一个支付表单翻车的真实故事

(1) 痛点:无效数据导致支付失败

Bob 在 ShopApp 结账页面输入信用卡号,少输了一位,系统没提示直接提交,支付被银行拒绝。再次输入时多了一个空格,还是失败。优惠券码更离谱——输入 "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 改用 Form 验证后,信用卡格式实时校验,优惠券码自动转大写,支付成功率从 70% 提升到 98%。


3. Form 体系

(1) Form 核心组件

组件 职责 关键属性
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 用户首次交互后 平衡体验与干扰

▶ 示例

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';

// 简化类定义
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) 常用 Formatter

Formatter 作用 示例输入 → 输出
FilteringTextInputFormatter.digitsOnly 仅数字 "abc123" → "123"
FilteringTextInputFormatter.allow(RegExp) 允许匹配 自定义正则
FilteringTextInputFormatter.deny(RegExp) 拒绝匹配 自定义正则
自定义 Formatter 任意格式化 信用卡/金额/电话

▶ 示例

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 _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 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 允许跨 Widget 访问 Form 的状态。
Q 异步 validator 怎么用?
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 或 controller 访问其他字段值。如密码确认字段读取密码字段值进行比对。

📖 小节


📝 作业

  1. 基础题(难度⭐):创建一个登录表单,包含邮箱和密码字段,邮箱验证格式,密码验证长度 ≥ 8。
  2. 进阶题(难度⭐⭐):实现信用卡号输入:4 位一组自动加空格,实时验证 Luhn 算法,MM/YY 日期格式化。
  3. 挑战题(难度⭐⭐⭐):实现完整的结账表单:收货地址 + 信用卡 + 优惠券码异步验证 + 自动计算折扣总价。

← 上一课 | 下一课 →

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

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

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