Flutter之处理用户输入

目录

  1. 总体思路
  2. 基础输入 Widget
  3. 输入状态管理
  4. 焦点与键盘控制
  5. 日期与时间选择
  6. 滑动与手势交互
  7. 自定义交互与无障碍
  8. 测试用户输入
  9. 练习建议

总体思路

  • Flutter 的输入处理是 声明式 的:UI 根据当前状态渲染,输入只需更新状态。
  • 每个输入控件都可以搭配 控制器 / 回调 获取变化。
  • 组合 是关键,复杂交互由多个基础 Widget 叠加实现。

基础输入 Widget

Text

  • Text 展示静态文本;若需要复制请选择 SelectableText,若需要局部样式则使用 RichText/TextSpan参考文档
  • SelectableText 支持长按选择、复制;RichText 支持不同 TextSpan 样式并可嵌套点击事件。

TextField

  • 最常见的单行/多行文本输入控件。
  • 关键属性:
    • controller:读取/设置文本。
    • decorationInputDecoration(标签、提示、边框、图标)。
    • keyboardType:文本、数字、email 等键盘。
    • onChangedonSubmitted:监听输入。
    • maxLines/minLinesobscureText(密码)、readOnly
  • 示例:
    final controller = TextEditingController();
    
    TextField(
      controller: controller,
      decoration: const InputDecoration(
        border: OutlineInputBorder(),
        labelText: 'Mascot Name',
      ),
      onSubmitted: (value) => debugPrint('提交:$value'),
    );
    

TextFormField

  • 构建在 Form 之上,适合集成验证逻辑。
  • 重要属性:validatoronSavedautovalidateMode
  • FormGlobalKey<FormState> 配合,可统一校验与保存。

完整示例:登录表单

class LoginForm extends StatefulWidget {
  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _obscurePassword = true;

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

  String? _validateEmail(String? value) {
    if (value == null || value.isEmpty) {
      return '请输入邮箱';
    }
    final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
    if (!emailRegex.hasMatch(value)) {
      return '请输入有效的邮箱地址';
    }
    return null;
  }

  String? _validatePassword(String? value) {
    if (value == null || value.isEmpty) {
      return '请输入密码';
    }
    if (value.length < 6) {
      return '密码至少需要6个字符';
    }
    return null;
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save();
      // 执行登录逻辑
      debugPrint('邮箱: ${_emailController.text}');
      debugPrint('密码: ${_passwordController.text}');
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('登录成功')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextFormField(
            controller: _emailController,
            decoration: const InputDecoration(
              labelText: '邮箱',
              hintText: 'example@email.com',
              prefixIcon: Icon(Icons.email),
              border: OutlineInputBorder(),
            ),
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            validator: _validateEmail,
          ),
          const SizedBox(height: 16),
          TextFormField(
            controller: _passwordController,
            decoration: InputDecoration(
              labelText: '密码',
              hintText: '至少6个字符',
              prefixIcon: const Icon(Icons.lock),
              border: const OutlineInputBorder(),
              suffixIcon: IconButton(
                icon: Icon(
                  _obscurePassword ? Icons.visibility : Icons.visibility_off,
                ),
                onPressed: () {
                  setState(() => _obscurePassword = !_obscurePassword);
                },
              ),
            ),
            obscureText: _obscurePassword,
            textInputAction: TextInputAction.done,
            validator: _validatePassword,
            onFieldSubmitted: (_) => _submit(),
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: _submit,
            child: const Padding(
              padding: EdgeInsets.all(16.0),
              child: Text('登录', style: TextStyle(fontSize: 16)),
            ),
          ),
        ],
      ),
    );
  }
}

自动验证模式

TextFormField(
  autovalidateMode: AutovalidateMode.onUserInteraction, // 用户交互时验证
  // AutovalidateMode.always - 始终验证
  // AutovalidateMode.disabled - 禁用自动验证(默认)
  validator: (value) => value!.isEmpty ? '不能为空' : null,
);

按钮系列

  • Material 3 常见按钮:
    • ElevatedButton:带阴影,适合强调主要操作。
    • FilledButton / FilledTonalButton:填充按钮,强调流程关键步骤。
    • OutlinedButton:带边框,用于次要操作。
    • TextButton:仅文本,适合轻量操作。
    • IconButton / FloatingActionButton:纯图标,FAB 用于页面主操作。
  • 三要素:child(内容)、onPressed(回调)、style(主题)。回调为 null 时按钮自动禁用。
  • 示例:
    int count = 0;
    
    ElevatedButton(
      style: ElevatedButton.styleFrom(textStyle: const TextStyle(fontSize: 20)),
      onPressed: () => setState(() => count++),
      child: const Text('Enabled'),
    );
    

选择与输入控件

SegmentedButton - 分段选择

enum Size { small, medium, large }

class SegmentedButtonExample extends StatefulWidget {
  @override
  State<SegmentedButtonExample> createState() => _SegmentedButtonExampleState();
}

class _SegmentedButtonExampleState extends State<SegmentedButtonExample> {
  Size selectedSize = Size.medium;

  @override
  Widget build(BuildContext context) {
    return SegmentedButton<Size>(
      segments: const [
        ButtonSegment(value: Size.small, label: Text('小'), icon: Icon(Icons.circle_outlined)),
        ButtonSegment(value: Size.medium, label: Text('中'), icon: Icon(Icons.circle)),
        ButtonSegment(value: Size.large, label: Text('大'), icon: Icon(Icons.circle_sharp)),
      ],
      selected: {selectedSize},
      onSelectionChanged: (Set<Size> newSelection) {
        setState(() => selectedSize = newSelection.first);
      },
    );
  }
}

// 多选模式
class MultiSelectSegmentedButton extends StatefulWidget {
  @override
  State<MultiSelectSegmentedButton> createState() => _MultiSelectSegmentedButtonState();
}

class _MultiSelectSegmentedButtonState extends State<MultiSelectSegmentedButton> {
  Set<String> selectedTags = {'Flutter'};

  @override
  Widget build(BuildContext context) {
    return SegmentedButton<String>(
      segments: const [
        ButtonSegment(value: 'Flutter', label: Text('Flutter')),
        ButtonSegment(value: 'Dart', label: Text('Dart')),
        ButtonSegment(value: 'Mobile', label: Text('Mobile')),
      ],
      selected: selectedTags,
      onSelectionChanged: (Set<String> newSelection) {
        setState(() => selectedTags = newSelection);
      },
      multiSelectionEnabled: true,
    );
  }
}

Chip 系列

// ChoiceChip - 单选
class ChoiceChipExample extends StatefulWidget {
  @override
  State<ChoiceChipExample> createState() => _ChoiceChipExampleState();
}

class _ChoiceChipExampleState extends State<ChoiceChipExample> {
  int selectedIndex = 0;
  final List<String> options = ['全部', '进行中', '已完成', '已取消'];

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8,
      children: List.generate(options.length, (index) {
        return ChoiceChip(
          label: Text(options[index]),
          selected: selectedIndex == index,
          onSelected: (selected) {
            setState(() => selectedIndex = index);
          },
        );
      }),
    );
  }
}

// FilterChip - 多选过滤
class FilterChipExample extends StatefulWidget {
  @override
  State<FilterChipExample> createState() => _FilterChipExampleState();
}

class _FilterChipExampleState extends State<FilterChipExample> {
  Set<String> selectedFilters = {'Flutter'};
  final List<String> filters = ['Flutter', 'Dart', 'Mobile', 'Web', 'Desktop'];

  @override
  Widget build(BuildContext context) {
    return Wrap(
      spacing: 8,
      children: filters.map((filter) {
        return FilterChip(
          label: Text(filter),
          selected: selectedFilters.contains(filter),
          onSelected: (selected) {
            setState(() {
              if (selected) {
                selectedFilters.add(filter);
              } else {
                selectedFilters.remove(filter);
              }
            });
          },
        );
      }).toList(),
    );
  }
}

DropdownMenu - 下拉菜单

class DropdownMenuExample extends StatefulWidget {
  @override
  State<DropdownMenuExample> createState() => _DropdownMenuExampleState();
}

class _DropdownMenuExampleState extends State<DropdownMenuExample> {
  String? selectedCountry;

  @override
  Widget build(BuildContext context) {
    return DropdownMenu<String>(
      label: const Text('选择国家'),
      initialSelection: selectedCountry,
      dropdownMenuEntries: const [
        DropdownMenuEntry(value: 'CN', label: '中国', leadingIcon: Icon(Icons.flag)),
        DropdownMenuEntry(value: 'US', label: '美国', leadingIcon: Icon(Icons.flag)),
        DropdownMenuEntry(value: 'JP', label: '日本', leadingIcon: Icon(Icons.flag)),
        DropdownMenuEntry(value: 'UK', label: '英国', leadingIcon: Icon(Icons.flag)),
      ],
      onSelected: (String? value) {
        setState(() => selectedCountry = value);
      },
    );
  }
}

Slider - 滑块

class SliderExample extends StatefulWidget {
  @override
  State<SliderExample> createState() => _SliderExampleState();
}

class _SliderExampleState extends State<SliderExample> {
  double volume = 50;
  RangeValues priceRange = const RangeValues(20, 80);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 普通滑块
        Text('音量: ${volume.round()}'),
        Slider(
          value: volume,
          min: 0,
          max: 100,
          divisions: 10,
          label: volume.round().toString(),
          onChanged: (value) => setState(() => volume = value),
        ),
        const SizedBox(height: 24),
        // 范围滑块
        Text('价格范围: ¥${priceRange.start.round()} - ¥${priceRange.end.round()}'),
        RangeSlider(
          values: priceRange,
          min: 0,
          max: 100,
          divisions: 20,
          labels: RangeLabels(
            '¥${priceRange.start.round()}',
            '¥${priceRange.end.round()}',
          ),
          onChanged: (values) => setState(() => priceRange = values),
        ),
      ],
    );
  }
}

Checkbox / Switch / Radio

class ToggleControlsExample extends StatefulWidget {
  @override
  State<ToggleControlsExample> createState() => _ToggleControlsExampleState();
}

class _ToggleControlsExampleState extends State<ToggleControlsExample> {
  bool acceptTerms = false;
  bool enableNotifications = true;
  String selectedGender = 'male';

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Checkbox
        CheckboxListTile(
          title: const Text('我同意服务条款'),
          value: acceptTerms,
          onChanged: (value) => setState(() => acceptTerms = value!),
          controlAffinity: ListTileControlAffinity.leading,
        ),
        
        // Switch
        SwitchListTile(
          title: const Text('启用通知'),
          subtitle: const Text('接收应用推送通知'),
          value: enableNotifications,
          onChanged: (value) => setState(() => enableNotifications = value),
        ),
        
        // Radio
        const Text('性别', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
        RadioListTile<String>(
          title: const Text('男'),
          value: 'male',
          groupValue: selectedGender,
          onChanged: (value) => setState(() => selectedGender = value!),
        ),
        RadioListTile<String>(
          title: const Text('女'),
          value: 'female',
          groupValue: selectedGender,
          onChanged: (value) => setState(() => selectedGender = value!),
        ),
        RadioListTile<String>(
          title: const Text('其他'),
          value: 'other',
          groupValue: selectedGender,
          onChanged: (value) => setState(() => selectedGender = value!),
        ),
      ],
    );
  }
}

输入状态管理

TextEditingController

  • 管理输入文本、监听变化,记得在 dispose() 中释放。
  • 可通过 addListener() 实现实时同步。

Form 与全局键

final formKey = GlobalKey<FormState>();

Form(
  key: formKey,
  child: Column(
    children: [
      TextFormField(validator: _validateEmail),
      ElevatedButton(
        onPressed: () {
          if (formKey.currentState!.validate()) {
            formKey.currentState!.save();
          }
        },
        child: const Text('提交'),
      ),
    ],
  ),
);

焦点与键盘控制

FocusNode 基础

  • FocusNode 用于管理输入控件的焦点状态。
  • 需要在 dispose() 中释放资源。
class MyWidget extends StatefulWidget {
  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  final FocusNode _focusNode = FocusNode();

  @override
  void dispose() {
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      focusNode: _focusNode,
      decoration: const InputDecoration(labelText: '用户名'),
    );
  }
}

焦点控制

  • 请求焦点_focusNode.requestFocus()FocusScope.of(context).requestFocus(_focusNode)
  • 取消焦点_focusNode.unfocus()FocusScope.of(context).unfocus()
  • 监听焦点变化_focusNode.addListener(() { if (_focusNode.hasFocus) { ... } })

键盘控制

// 隐藏键盘
FocusScope.of(context).unfocus();

// 或者
FocusManager.instance.primaryFocus?.unfocus();

// 切换到下一个输入框
FocusScope.of(context).nextFocus();

// 切换到上一个输入框
FocusScope.of(context).previousFocus();

键盘快捷键

Shortcuts(
  shortcuts: {
    LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyS): 
      const SaveIntent(),
  },
  child: Actions(
    actions: {
      SaveIntent: CallbackAction<SaveIntent>(
        onInvoke: (intent) => _save(),
      ),
    },
    child: Focus(
      autofocus: true,
      child: YourWidget(),
    ),
  ),
);

FocusTraversalGroup

  • 管理 Tab 键的焦点遍历顺序。
FocusTraversalGroup(
  policy: OrderedTraversalPolicy(),
  child: Column(
    children: [
      FocusTraversalOrder(
        order: NumericFocusOrder(1.0),
        child: TextField(decoration: InputDecoration(labelText: '第一个')),
      ),
      FocusTraversalOrder(
        order: NumericFocusOrder(2.0),
        child: TextField(decoration: InputDecoration(labelText: '第二个')),
      ),
    ],
  ),
);

日期与时间选择

日期选择器

class DatePickerExample extends StatefulWidget {
  @override
  State<DatePickerExample> createState() => _DatePickerExampleState();
}

class _DatePickerExampleState extends State<DatePickerExample> {
  DateTime? selectedDate;

  Future<void> _selectDate() async {
    final DateTime? picked = await showDatePicker(
      context: context,
      initialDate: selectedDate ?? DateTime.now(),
      firstDate: DateTime(2000),
      lastDate: DateTime(2100),
      // 自定义样式
      helpText: '选择日期',
      cancelText: '取消',
      confirmText: '确定',
      // 初始显示模式:日历或输入
      initialEntryMode: DatePickerEntryMode.calendar,
      // 日期选择模式:日、年
      initialDatePickerMode: DatePickerMode.day,
    );

    if (picked != null && picked != selectedDate) {
      setState(() => selectedDate = picked);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          selectedDate == null
              ? '未选择日期'
              : '选择的日期: ${selectedDate!.year}-${selectedDate!.month}-${selectedDate!.day}',
        ),
        const SizedBox(height: 16),
        ElevatedButton.icon(
          onPressed: _selectDate,
          icon: const Icon(Icons.calendar_today),
          label: const Text('选择日期'),
        ),
      ],
    );
  }
}

时间选择器

class TimePickerExample extends StatefulWidget {
  @override
  State<TimePickerExample> createState() => _TimePickerExampleState();
}

class _TimePickerExampleState extends State<TimePickerExample> {
  TimeOfDay? selectedTime;

  Future<void> _selectTime() async {
    final TimeOfDay? picked = await showTimePicker(
      context: context,
      initialTime: selectedTime ?? TimeOfDay.now(),
      // 自定义文本
      helpText: '选择时间',
      cancelText: '取消',
      confirmText: '确定',
      // 时间输入模式:拨盘或输入
      initialEntryMode: TimePickerEntryMode.dial,
      // 小时格式:12小时制或24小时制
      builder: (context, child) {
        return MediaQuery(
          data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true),
          child: child!,
        );
      },
    );

    if (picked != null && picked != selectedTime) {
      setState(() => selectedTime = picked);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          selectedTime == null
              ? '未选择时间'
              : '选择的时间: ${selectedTime!.hour}:${selectedTime!.minute.toString().padLeft(2, '0')}',
        ),
        const SizedBox(height: 16),
        ElevatedButton.icon(
          onPressed: _selectTime,
          icon: const Icon(Icons.access_time),
          label: const Text('选择时间'),
        ),
      ],
    );
  }
}

日期范围选择器

class DateRangePickerExample extends StatefulWidget {
  @override
  State<DateRangePickerExample> createState() => _DateRangePickerExampleState();
}

class _DateRangePickerExampleState extends State<DateRangePickerExample> {
  DateTimeRange? selectedDateRange;

  Future<void> _selectDateRange() async {
    final DateTimeRange? picked = await showDateRangePicker(
      context: context,
      firstDate: DateTime(2000),
      lastDate: DateTime(2100),
      initialDateRange: selectedDateRange,
      helpText: '选择日期范围',
      cancelText: '取消',
      confirmText: '确定',
      saveText: '保存',
    );

    if (picked != null && picked != selectedDateRange) {
      setState(() => selectedDateRange = picked);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(
          selectedDateRange == null
              ? '未选择日期范围'
              : '开始: ${selectedDateRange!.start.year}-${selectedDateRange!.start.month}-${selectedDateRange!.start.day}\n'
                '结束: ${selectedDateRange!.end.year}-${selectedDateRange!.end.month}-${selectedDateRange!.end.day}',
          textAlign: TextAlign.center,
        ),
        const SizedBox(height: 16),
        ElevatedButton.icon(
          onPressed: _selectDateRange,
          icon: const Icon(Icons.date_range),
          label: const Text('选择日期范围'),
        ),
      ],
    );
  }
}

完整示例:预约表单

class AppointmentForm extends StatefulWidget {
  @override
  State<AppointmentForm> createState() => _AppointmentFormState();
}

class _AppointmentFormState extends State<AppointmentForm> {
  DateTime? selectedDate;
  TimeOfDay? selectedTime;

  Future<void> _selectDate() async {
    final picked = await showDatePicker(
      context: context,
      initialDate: selectedDate ?? DateTime.now(),
      firstDate: DateTime.now(),
      lastDate: DateTime.now().add(const Duration(days: 365)),
    );
    if (picked != null) setState(() => selectedDate = picked);
  }

  Future<void> _selectTime() async {
    final picked = await showTimePicker(
      context: context,
      initialTime: selectedTime ?? TimeOfDay.now(),
    );
    if (picked != null) setState(() => selectedTime = picked);
  }

  void _submit() {
    if (selectedDate == null || selectedTime == null) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('请选择日期和时间')),
      );
      return;
    }

    final appointment = DateTime(
      selectedDate!.year,
      selectedDate!.month,
      selectedDate!.day,
      selectedTime!.hour,
      selectedTime!.minute,
    );

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('预约时间: $appointment')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text('预约服务', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
            const SizedBox(height: 16),
            ListTile(
              leading: const Icon(Icons.calendar_today),
              title: Text(selectedDate == null
                  ? '选择日期'
                  : '${selectedDate!.year}-${selectedDate!.month}-${selectedDate!.day}'),
              trailing: const Icon(Icons.arrow_forward_ios, size: 16),
              onTap: _selectDate,
            ),
            const Divider(),
            ListTile(
              leading: const Icon(Icons.access_time),
              title: Text(selectedTime == null
                  ? '选择时间'
                  : '${selectedTime!.hour}:${selectedTime!.minute.toString().padLeft(2, '0')}'),
              trailing: const Icon(Icons.arrow_forward_ios, size: 16),
              onTap: _selectTime,
            ),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: _submit,
              child: const Padding(
                padding: EdgeInsets.all(16),
                child: Text('确认预约'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

滑动与手势交互

Dismissible

  • 通过滑动删除列表项。
  • 必须提供唯一 key
  • background / secondaryBackground 自定义效果。
  • direction 控制滑动方向。
  • confirmDismiss 可以在删除前弹出确认对话框。
Dismissible(
  key: ValueKey(item.id),
  direction: DismissDirection.endToStart, // 只允许从右向左滑动
  background: Container(
    color: Colors.red,
    alignment: Alignment.centerRight,
    padding: const EdgeInsets.only(right: 20),
    child: const Icon(Icons.delete, color: Colors.white),
  ),
  confirmDismiss: (direction) async {
    return await showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('确认删除'),
        content: const Text('确定要删除这一项吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: const Text('取消'),
          ),
          TextButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: const Text('删除'),
          ),
        ],
      ),
    );
  },
  onDismissed: (direction) {
    setState(() => items.remove(item));
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('${item.title} 已删除')),
    );
  },
  child: ListTile(title: Text(item.title)),
);

GestureDetector

  • 自定义点击、拖拽、长按等手势。
  • 常用回调:
    • 点击onTaponDoubleTaponLongPress
    • 拖拽onPanStartonPanUpdateonPanEnd
    • 缩放onScaleStartonScaleUpdateonScaleEnd
    • 垂直/水平拖动onVerticalDragUpdateonHorizontalDragUpdate
class DraggableBox extends StatefulWidget {
  @override
  State<DraggableBox> createState() => _DraggableBoxState();
}

class _DraggableBoxState extends State<DraggableBox> {
  Offset position = Offset.zero;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanUpdate: (details) {
        setState(() {
          position += details.delta;
        });
      },
      onDoubleTap: () {
        setState(() {
          position = Offset.zero; // 双击重置位置
        });
      },
      child: Transform.translate(
        offset: position,
        child: Container(
          width: 100,
          height: 100,
          color: Colors.blue,
          child: const Center(child: Text('拖动我')),
        ),
      ),
    );
  }
}

InkWell 与 InkResponse

  • 提供 Material 风格的水波纹效果。
  • InkWell 填充整个区域,InkResponse 仅响应点击区域。
InkWell(
  onTap: () => debugPrint('点击'),
  onLongPress: () => debugPrint('长按'),
  borderRadius: BorderRadius.circular(8),
  splashColor: Colors.blue.withOpacity(0.3),
  child: Container(
    padding: const EdgeInsets.all(16),
    child: const Text('点击我'),
  ),
);

Draggable 与 DragTarget

  • 实现拖放功能。
// 可拖动的元素
Draggable<String>(
  data: 'item_data',
  feedback: Container(
    width: 100,
    height: 100,
    color: Colors.blue.withOpacity(0.5),
    child: const Center(child: Text('拖动中')),
  ),
  childWhenDragging: Container(
    width: 100,
    height: 100,
    color: Colors.grey,
  ),
  child: Container(
    width: 100,
    height: 100,
    color: Colors.blue,
    child: const Center(child: Text('拖动我')),
  ),
);

// 拖放目标
DragTarget<String>(
  onAccept: (data) {
    debugPrint('接收到:$data');
  },
  builder: (context, candidateData, rejectedData) {
    return Container(
      width: 200,
      height: 200,
      color: candidateData.isEmpty ? Colors.grey : Colors.green,
      child: const Center(child: Text('放到这里')),
    );
  },
);

自定义交互与无障碍

自定义交互控件

class CustomButton extends StatefulWidget {
  final VoidCallback onPressed;
  final Widget child;

  const CustomButton({
    required this.onPressed,
    required this.child,
    super.key,
  });

  @override
  State<CustomButton> createState() => _CustomButtonState();
}

class _CustomButtonState extends State<CustomButton> {
  bool _isPressed = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => setState(() => _isPressed = true),
      onTapUp: (_) => setState(() => _isPressed = false),
      onTapCancel: () => setState(() => _isPressed = false),
      onTap: widget.onPressed,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 100),
        padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
        decoration: BoxDecoration(
          color: _isPressed ? Colors.blue.shade700 : Colors.blue,
          borderRadius: BorderRadius.circular(8),
          boxShadow: _isPressed
              ? []
              : [BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(0, 2))],
        ),
        child: widget.child,
      ),
    );
  }
}

无障碍支持

  • 使用 Semantics 为自定义控件添加语义信息。
  • 确保所有交互元素都有合适的标签和提示。
Semantics(
  label: '提交按钮',
  hint: '点击提交表单',
  button: true,
  enabled: true,
  child: CustomButton(
    onPressed: _submit,
    child: const Text('提交'),
  ),
);

// 为图片添加描述
Semantics(
  label: '用户头像',
  image: true,
  child: Image.network(avatarUrl),
);

// 排除不需要读屏的装饰元素
ExcludeSemantics(
  child: Container(
    decoration: BoxDecoration(/* 装饰性图案 */),
  ),
);

// 合并多个元素的语义
MergeSemantics(
  child: Row(
    children: [
      Icon(Icons.star),
      Text('5.0'),
      Text('评分'),
    ],
  ),
);

Tooltip 提示

Tooltip(
  message: '这是一个提示信息',
  child: IconButton(
    icon: const Icon(Icons.info),
    onPressed: () {},
  ),
);

// 自定义 Tooltip 样式
Tooltip(
  message: '保存文件',
  decoration: BoxDecoration(
    color: Colors.blue,
    borderRadius: BorderRadius.circular(4),
  ),
  textStyle: const TextStyle(color: Colors.white),
  preferBelow: false,
  verticalOffset: 20,
  child: IconButton(
    icon: const Icon(Icons.save),
    onPressed: _save,
  ),
);

测试用户输入

Widget 测试基础

import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('测试文本输入', (WidgetTester tester) async {
    // 构建 Widget
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: TextField(
            key: const ValueKey('username'),
          ),
        ),
      ),
    );

    // 查找输入框
    final textField = find.byKey(const ValueKey('username'));
    expect(textField, findsOneWidget);

    // 输入文本
    await tester.enterText(textField, 'test_user');
    await tester.pump();

    // 验证文本
    expect(find.text('test_user'), findsOneWidget);
  });

  testWidgets('测试按钮点击', (WidgetTester tester) async {
    int counter = 0;

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: StatefulBuilder(
            builder: (context, setState) {
              return ElevatedButton(
                onPressed: () => setState(() => counter++),
                child: Text('点击次数: $counter'),
              );
            },
          ),
        ),
      ),
    );

    // 点击按钮
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump();

    // 验证结果
    expect(find.text('点击次数: 1'), findsOneWidget);
  });

  testWidgets('测试滑动操作', (WidgetTester tester) async {
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: ListView.builder(
            itemCount: 100,
            itemBuilder: (context, index) => ListTile(
              title: Text('Item $index'),
            ),
          ),
        ),
      ),
    );

    // 验证初始状态
    expect(find.text('Item 0'), findsOneWidget);
    expect(find.text('Item 50'), findsNothing);

    // 向上滑动
    await tester.drag(find.byType(ListView), const Offset(0, -3000));
    await tester.pumpAndSettle();

    // 验证滑动后的状态
    expect(find.text('Item 0'), findsNothing);
    expect(find.text('Item 50'), findsOneWidget);
  });

  testWidgets('测试表单验证', (WidgetTester tester) async {
    final formKey = GlobalKey<FormState>();

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: Form(
            key: formKey,
            child: Column(
              children: [
                TextFormField(
                  key: const ValueKey('email'),
                  validator: (value) {
                    if (value == null || !value.contains('@')) {
                      return '请输入有效的邮箱';
                    }
                    return null;
                  },
                ),
                ElevatedButton(
                  onPressed: () => formKey.currentState!.validate(),
                  child: const Text('提交'),
                ),
              ],
            ),
          ),
        ),
      ),
    );

    // 输入无效邮箱
    await tester.enterText(find.byKey(const ValueKey('email')), 'invalid');
    await tester.tap(find.text('提交'));
    await tester.pump();

    // 验证错误信息
    expect(find.text('请输入有效的邮箱'), findsOneWidget);

    // 输入有效邮箱
    await tester.enterText(find.byKey(const ValueKey('email')), 'test@example.com');
    await tester.tap(find.text('提交'));
    await tester.pump();

    // 验证错误信息消失
    expect(find.text('请输入有效的邮箱'), findsNothing);
  });
}

高级技巧与最佳实践

输入防抖与节流

import 'dart:async';

class Debouncer {
  final Duration delay;
  Timer? _timer;

  Debouncer({required this.delay});

  void call(VoidCallback action) {
    _timer?.cancel();
    _timer = Timer(delay, action);
  }

  void dispose() {
    _timer?.cancel();
  }
}

// 使用示例
class SearchWidget extends StatefulWidget {
  @override
  State<SearchWidget> createState() => _SearchWidgetState();
}

class _SearchWidgetState extends State<SearchWidget> {
  final _debouncer = Debouncer(delay: const Duration(milliseconds: 500));

  @override
  void dispose() {
    _debouncer.dispose();
    super.dispose();
  }

  void _onSearchChanged(String query) {
    _debouncer(() {
      // 执行搜索
      debugPrint('搜索: $query');
    });
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      onChanged: _onSearchChanged,
      decoration: const InputDecoration(
        labelText: '搜索',
        prefixIcon: Icon(Icons.search),
      ),
    );
  }
}

输入格式化

import 'package:flutter/services.dart';

// 手机号格式化
class PhoneNumberFormatter 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++) {
      buffer.write(text[i]);
      if ((i == 2 || i == 6) && i != text.length - 1) {
        buffer.write(' ');
      }
    }

    return TextEditingValue(
      text: buffer.toString(),
      selection: TextSelection.collapsed(offset: buffer.length),
    );
  }
}

// 使用示例
TextField(
  keyboardType: TextInputType.phone,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(11),
    PhoneNumberFormatter(),
  ],
  decoration: const InputDecoration(
    labelText: '手机号',
    hintText: '138 0000 0000',
  ),
);

表单状态保存与恢复

class MyForm extends StatefulWidget {
  @override
  State<MyForm> createState() => _MyFormState();
}

class _MyFormState extends State<MyForm> with RestorationMixin {
  final RestorableTextEditingController _nameController = 
    RestorableTextEditingController();
  final RestorableBool _agreedToTerms = RestorableBool(false);

  @override
  String? get restorationId => 'my_form';

  @override
  void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
    registerForRestoration(_nameController, 'name');
    registerForRestoration(_agreedToTerms, 'agreed');
  }

  @override
  void dispose() {
    _nameController.dispose();
    _agreedToTerms.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(controller: _nameController.value),
        CheckboxListTile(
          value: _agreedToTerms.value,
          onChanged: (value) => setState(() => _agreedToTerms.value = value!),
          title: const Text('同意条款'),
        ),
      ],
    );
  }
}

多语言输入支持

TextField(
  decoration: const InputDecoration(
    labelText: 'Name',
  ),
  textInputAction: TextInputAction.next,
  textCapitalization: TextCapitalization.words,
  // 支持多语言输入法
  enableIMEPersonalizedLearning: true,
  // 自动纠错
  autocorrect: true,
  // 智能提示
  enableSuggestions: true,
);

练习建议

初级练习

  1. 登录表单:创建包含用户名、密码输入框和登录按钮的表单,添加基本验证。
  2. 计数器应用:使用不同类型的按钮实现加减功能。
  3. 待办列表:实现添加、删除待办事项,使用 Dismissible 滑动删除。

中级练习

  1. 多步骤注册表单:包含验证、焦点管理、进度指示。
  2. 搜索功能:实现带防抖的实时搜索,显示搜索结果。
  3. 设置页面:使用各种输入控件(Switch、Slider、DropdownMenu 等)。
  4. 日期范围选择器:选择开始和结束日期,验证日期有效性。

高级练习

  1. 自定义表单控件:创建可复用的自定义输入组件,支持验证和格式化。
  2. 拖放排序列表:实现可拖动排序的列表。
  3. 富文本编辑器:支持文本格式化、插入图片等功能。
  4. 手势识别游戏:使用 GestureDetector 实现滑动、缩放等交互。
  5. 完整的表单测试套件:为复杂表单编写全面的 Widget 测试。

实战项目

  1. 问卷调查应用:支持多种题型(单选、多选、填空、评分)。
  2. 笔记应用:支持富文本编辑、标签管理、搜索过滤。
  3. 电商购物车:商品数量调整、滑动删除、优惠券输入。
  4. 社交媒体发布:文本输入、图片上传、话题标签、@提及功能。

常见问题与解决方案

键盘遮挡输入框

Scaffold(
  resizeToAvoidBottomInset: true, // 默认为 true
  body: SingleChildScrollView(
    child: Padding(
      padding: EdgeInsets.only(
        bottom: MediaQuery.of(context).viewInsets.bottom,
      ),
      child: YourForm(),
    ),
  ),
);

TextField 性能优化

// 对于大量 TextField,使用 AutomaticKeepAliveClientMixin
class MyTextField extends StatefulWidget {
  @override
  State<MyTextField> createState() => _MyTextFieldState();
}

class _MyTextFieldState extends State<MyTextField> 
    with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context); // 必须调用
    return TextField(/* ... */);
  }
}

监听输入变化的最佳方式

// 方式 1:使用 onChanged(简单场景)
TextField(
  onChanged: (value) => debugPrint(value),
);

// 方式 2:使用 Controller(需要程序化控制)
final controller = TextEditingController();
controller.addListener(() {
  debugPrint(controller.text);
});

// 方式 3:使用 ValueListenableBuilder(避免整体重建)
ValueListenableBuilder<TextEditingValue>(
  valueListenable: controller,
  builder: (context, value, child) {
    return Text('输入了 ${value.text.length} 个字符');
  },
);

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容