代碼案例:表單處理

25 阅读4分钟

表單處理

內容: 對應 react-hook-form / Formik,Flutter 表單兩種方式 + 完整登錄頁示例,3 個 Tab 演示。

Tab 1:Form + TextFormField(官方表單,推薦)

  • 等價 react-hook-form 的 useForm + register + handleSubmit
  • GlobalKey<FormState> 控制校驗/重置/保存
  • autovalidateMode 三種觸發時機(disabled / onUserInteraction / always)
  • validator 返回 null = 通過,返回字符串 = 錯誤信息
  • 密碼可顯示/隱藏的 _PasswordField 組件

Tab 2:受控組件(TextEditingController)

  • 等價 React 受控組件(value + onChange
  • 程式碼設置值、清空、光標定位
  • inputFormatters 限制輸入(只允許數字/小數)
  • 多行文本(maxLines)、字數限制(maxLength

Tab 3:完整登錄表單

  • 郵箱 + 密碼校驗
  • Loading 狀態按鈕禁用
  • 服務端錯誤提示(模擬 setError('root', {...})
  • mounted 防止組件卸載後 setState

运行

在线效果可以复制到:dartpad.dev/ 查看,更推荐本地flutter build web调试学习

效果图

image.png

学习代码

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

// ============================================================
// Flutter 表单处理
// 等价于 react-hook-form / Formik
//
// Flutter 表单两种方式:
//   Form + TextFormField(官方,等价于 react-hook-form)
//   TextEditingController(手动控制,等价于受控组件)
// ============================================================

void main() => runApp(const MaterialApp(home: _FormDemo()));

class _FormDemo extends StatelessWidget {
  const _FormDemo();

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('表单处理'),
          bottom: const TabBar(tabs: [
            Tab(text: 'Form 校验'),
            Tab(text: '受控组件'),
            Tab(text: '完整登录'),
          ]),
        ),
        body: const TabBarView(children: [
          _FormValidationDemo(),
          _ControlledInputDemo(),
          _LoginFormDemo(),
        ]),
      ),
    );
  }
}

// ============================================================
// 一、Form + TextFormField(官方表单,推荐)
// 等价于 react-hook-form 的 useForm + register + handleSubmit
// ============================================================
class _FormValidationDemo extends StatefulWidget {
  const _FormValidationDemo();

  @override
  State<_FormValidationDemo> createState() => _FormValidationDemoState();
}

class _FormValidationDemoState extends State<_FormValidationDemo> {
  // 等价于 react-hook-form 的 useForm()
  // GlobalKey 用于访问 FormState(validate / reset / save)
  final _formKey = GlobalKey<FormState>();

  // 存储表单值
  String _name = '';
  String _email = '';
  String _password = '';

  void _submit() {
    // 等价于 handleSubmit(onSubmit)
    // validate() 会触发所有字段的 validator,返回 true = 全部通过
    if (_formKey.currentState!.validate()) {
      // 调用 save() 触发所有字段的 onSaved 回调
      _formKey.currentState!.save();

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('提交成功:$_name / $_email')),
      );
    }
  }

  void _reset() {
    // 等价于 reset()
    _formKey.currentState!.reset();
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Form(
        key: _formKey,
        // autovalidateMode: 校验触发时机
        // AutovalidateMode.disabled         → 只在 validate() 时校验(默认)
        // AutovalidateMode.onUserInteraction → 用户输入后实时校验
        // AutovalidateMode.always            → 始终校验
        autovalidateMode: AutovalidateMode.onUserInteraction,
        child: Column(
          children: [
            // ── 姓名字段 ────────────────────────────────────
            // 等价于:
            // <input {...register('name', { required: '请输入姓名', minLength: 2 })} />
            TextFormField(
              decoration: const InputDecoration(
                labelText: '姓名',
                hintText: '请输入姓名',
                prefixIcon: Icon(Icons.person),
              ),
              // validator:等价于 react-hook-form 的 validate 规则
              validator: (value) {
                if (value == null || value.isEmpty) return '请输入姓名';
                if (value.length < 2) return '姓名至少 2 个字符';
                return null; // null = 校验通过
              },
              // onSaved:调用 formKey.currentState!.save() 时触发
              onSaved: (value) => _name = value ?? '',
            ),
            const SizedBox(height: 16),

            // ── 邮箱字段 ────────────────────────────────────
            TextFormField(
              decoration: const InputDecoration(
                labelText: '邮箱',
                prefixIcon: Icon(Icons.email),
              ),
              keyboardType: TextInputType.emailAddress, // 弹出邮箱键盘
              validator: (value) {
                if (value == null || value.isEmpty) return '请输入邮箱';
                // 正则校验
                final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
                if (!emailRegex.hasMatch(value)) return '邮箱格式不正确';
                return null;
              },
              onSaved: (value) => _email = value ?? '',
            ),
            const SizedBox(height: 16),

            // ── 密码字段 ────────────────────────────────────
            _PasswordField(
              label: '密码',
              validator: (value) {
                if (value == null || value.isEmpty) return '请输入密码';
                if (value.length < 6) return '密码至少 6 位';
                return null;
              },
              onSaved: (value) => _password = value ?? '',
            ),
            const SizedBox(height: 24),

            // ── 提交 / 重置 ─────────────────────────────────
            Row(
              children: [
                Expanded(
                  child: ElevatedButton(
                    onPressed: _submit,
                    child: const Text('提交'),
                  ),
                ),
                const SizedBox(width: 16),
                Expanded(
                  child: OutlinedButton(
                    onPressed: _reset,
                    child: const Text('重置'),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

// 密码输入框(可切换显示/隐藏)
class _PasswordField extends StatefulWidget {
  final String label;
  final FormFieldValidator<String>? validator;
  final FormFieldSetter<String>? onSaved;

  const _PasswordField({required this.label, this.validator, this.onSaved});

  @override
  State<_PasswordField> createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<_PasswordField> {
  bool _obscure = true;

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      obscureText: _obscure, // 密码遮罩
      decoration: InputDecoration(
        labelText: widget.label,
        prefixIcon: const Icon(Icons.lock),
        // 右侧眼睛图标切换显隐(等价于 React 受控 type="password" 切换)
        suffixIcon: IconButton(
          icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility),
          onPressed: () => setState(() => _obscure = !_obscure),
        ),
      ),
      validator: widget.validator,
      onSaved: widget.onSaved,
    );
  }
}

// ============================================================
// 二、受控组件(TextEditingController)
// 等价于 React 受控组件:value + onChange
// ============================================================
class _ControlledInputDemo extends StatefulWidget {
  const _ControlledInputDemo();

  @override
  State<_ControlledInputDemo> createState() => _ControlledInputDemoState();
}

class _ControlledInputDemoState extends State<_ControlledInputDemo> {
  // 等价于:const [value, setValue] = useState('')
  final TextEditingController _controller = TextEditingController();
  final TextEditingController _priceController = TextEditingController();

  @override
  void dispose() {
    // 必须释放,等价于 React useEffect cleanup
    _controller.dispose();
    _priceController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          // 基础受控输入
          TextField(
            controller: _controller,
            decoration: const InputDecoration(labelText: '受控输入'),
            // 等价于 onChange:实时监听输入
            onChanged: (value) {
              setState(() {}); // 触发重建显示实时预览
            },
          ),
          Text('实时值:${_controller.text}'),
          const SizedBox(height: 16),

          // 程序化设置值(等价于 setValue('xxx'))
          Row(
            children: [
              TextButton(
                onPressed: () {
                  _controller.text = '程序设置的值';
                  // 光标移到末尾
                  _controller.selection = TextSelection.fromPosition(
                    TextPosition(offset: _controller.text.length),
                  );
                },
                child: const Text('程序设置值'),
              ),
              TextButton(
                onPressed: () => _controller.clear(), // 等价于 setValue('')
                child: const Text('清空'),
              ),
            ],
          ),
          const SizedBox(height: 16),

          // 只允许输入数字(等价于 <input type="number">)
          TextField(
            controller: _priceController,
            decoration: const InputDecoration(
              labelText: '价格(只能输入数字)',
              prefixText: '¥ ',
            ),
            keyboardType: const TextInputType.numberWithOptions(decimal: true),
            inputFormatters: [
              // 过滤非数字字符,等价于 onChange 里的正则过滤
              FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
            ],
          ),
          const SizedBox(height: 16),

          // 多行文本(等价于 <textarea>)
          const TextField(
            maxLines: 4, // null = 无限行
            minLines: 2,
            decoration: InputDecoration(
              labelText: '多行文本',
              alignLabelWithHint: true,
            ),
          ),
          const SizedBox(height: 16),

          // 字符数限制(等价于 maxLength 属性)
          const TextField(
            maxLength: 100, // 自动显示字符计数
            decoration: InputDecoration(labelText: '限制 100 字'),
          ),
        ],
      ),
    );
  }
}

// ============================================================
// 三、完整登录表单(综合示例)
// 等价于 react-hook-form 完整登录页
// ============================================================
class _LoginFormDemo extends StatefulWidget {
  const _LoginFormDemo();

  @override
  State<_LoginFormDemo> createState() => _LoginFormDemoState();
}

class _LoginFormDemoState extends State<_LoginFormDemo> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _isLoading = false;
  String? _serverError; // 服务端返回的错误(等价于 setError('root', {...}))

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future<void> _login() async {
    // 清除服务端错误
    setState(() => _serverError = null);

    if (!_formKey.currentState!.validate()) return;

    setState(() => _isLoading = true);

    try {
      // 模拟请求
      await Future.delayed(const Duration(seconds: 1));

      // 模拟服务端错误(等价于 setError('root', { message: '...' }))
      if (_emailController.text != 'test@test.com') {
        setState(() => _serverError = '邮箱或密码错误');
        return;
      }

      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('登录成功!')),
        );
      }
    } finally {
      if (mounted) setState(() => _isLoading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      padding: const EdgeInsets.all(24),
      child: Form(
        key: _formKey,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const SizedBox(height: 32),
            Text(
              '登录',
              style: Theme.of(context).textTheme.headlineMedium,
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 32),

            // 邮箱
            TextFormField(
              controller: _emailController,
              decoration: const InputDecoration(
                labelText: '邮箱',
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.email_outlined),
              ),
              keyboardType: TextInputType.emailAddress,
              textInputAction: TextInputAction.next, // 键盘下一步按钮
              validator: (v) {
                if (v == null || v.isEmpty) return '请输入邮箱';
                if (!v.contains('@')) return '邮箱格式不正确';
                return null;
              },
            ),
            const SizedBox(height: 16),

            // 密码
            _PasswordField(
              label: '密码',
              validator: (v) {
                if (v == null || v.isEmpty) return '请输入密码';
                if (v.length < 6) return '密码至少 6 位';
                return null;
              },
            ),
            const SizedBox(height: 8),

            // 服务端错误提示(等价于 errors.root?.message)
            if (_serverError != null)
              Padding(
                padding: const EdgeInsets.only(bottom: 8),
                child: Text(
                  _serverError!,
                  style: TextStyle(color: Theme.of(context).colorScheme.error),
                ),
              ),

            // 忘记密码
            Align(
              alignment: Alignment.centerRight,
              child: TextButton(
                onPressed: () {},
                child: const Text('忘记密码?'),
              ),
            ),
            const SizedBox(height: 16),

            // 提交按钮(loading 状态禁用)
            ElevatedButton(
              onPressed: _isLoading ? null : _login,
              style: ElevatedButton.styleFrom(
                padding: const EdgeInsets.symmetric(vertical: 14),
              ),
              child: _isLoading
                  ? const SizedBox(
                      width: 20,
                      height: 20,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : const Text('登录', style: TextStyle(fontSize: 16)),
            ),
            const SizedBox(height: 16),

            // 提示文字
            const Text(
              '测试账号:test@test.com / 任意密码',
              textAlign: TextAlign.center,
              style: TextStyle(color: Colors.grey),
            ),
          ],
        ),
      ),
    );
  }
}

// ============================================================
// 附:react-hook-form vs Flutter Form 对照
//
// react-hook-form               Flutter Form
// ─────────────────────────────────────────────────────────
// useForm()                     GlobalKey<FormState>
// register('field', rules)      TextFormField(validator: ...)
// handleSubmit(onSubmit)        formKey.currentState!.validate()
// reset()                       formKey.currentState!.reset()
// watch('field')                TextEditingController.text
// setValue('field', value)      controller.text = value
// setError('field', msg)        setState(() => _error = msg)
// errors.field?.message         validator 返回的错误字符串
// trigger('field')              formKey.currentState!.validate()
// <input type="number">         keyboardType: TextInputType.number
// <textarea rows={4}>           TextField(maxLines: 4)
// maxLength={100}               TextField(maxLength: 100)
// <input type="password">       TextField(obscureText: true)
// onChange                      onChanged
// onBlur                        onEditingComplete / focusNode.addListener
// ============================================================