Flutter之處理用戶輸入

目錄

  1. 總體思路
  2. 基礎(chǔ)輸入 Widget
  3. 輸入狀態(tài)管理
  4. 焦點(diǎn)與鍵盤控制
  5. 日期與時間選擇
  6. 滑動與手勢交互
  7. 自定義交互與無障礙
  8. 測試用戶輸入
  9. 練習(xí)建議

總體思路

  • Flutter 的輸入處理是 聲明式 的:UI 根據(jù)當(dāng)前狀態(tài)渲染,輸入只需更新狀態(tài)。
  • 每個輸入控件都可以搭配 控制器 / 回調(diào) 獲取變化。
  • 組合 是關(guān)鍵,復(fù)雜交互由多個基礎(chǔ) Widget 疊加實(shí)現(xiàn)。

基礎(chǔ)輸入 Widget

Text

  • Text 展示靜態(tài)文本;若需要復(fù)制請選擇 SelectableText,若需要局部樣式則使用 RichText/TextSpan。參考文檔
  • SelectableText 支持長按選擇、復(fù)制;RichText 支持不同 TextSpan 樣式并可嵌套點(diǎn)擊事件。

TextField

  • 最常見的單行/多行文本輸入控件。
  • 關(guān)鍵屬性:
    • controller:讀取/設(shè)置文本。
    • decorationInputDecoration(標(biāo)簽、提示、邊框、圖標(biāo))。
    • keyboardType:文本、數(shù)字、email 等鍵盤。
    • onChanged、onSubmitted:監(jiān)聽輸入。
    • maxLines/minLinesobscureText(密碼)、readOnly。
  • 示例:
    final controller = TextEditingController();
    
    TextField(
      controller: controller,
      decoration: const InputDecoration(
        border: OutlineInputBorder(),
        labelText: 'Mascot Name',
      ),
      onSubmitted: (value) => debugPrint('提交:$value'),
    );
    

TextFormField

  • 構(gòu)建在 Form 之上,適合集成驗(yàn)證邏輯。
  • 重要屬性:validatoronSaved、autovalidateMode
  • FormGlobalKey<FormState> 配合,可統(tǒng)一校驗(yàn)與保存。

完整示例:登錄表單

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();
      // 執(zhí)行登錄邏輯
      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)),
            ),
          ),
        ],
      ),
    );
  }
}

自動驗(yàn)證模式

TextFormField(
  autovalidateMode: AutovalidateMode.onUserInteraction, // 用戶交互時驗(yàn)證
  // AutovalidateMode.always - 始終驗(yàn)證
  // AutovalidateMode.disabled - 禁用自動驗(yàn)證(默認(rèn))
  validator: (value) => value!.isEmpty ? '不能為空' : null,
);

按鈕系列

  • Material 3 常見按鈕:
    • ElevatedButton:帶陰影,適合強(qiáng)調(diào)主要操作。
    • FilledButton / FilledTonalButton:填充按鈕,強(qiáng)調(diào)流程關(guān)鍵步驟。
    • OutlinedButton:帶邊框,用于次要操作。
    • TextButton:僅文本,適合輕量操作。
    • IconButton / FloatingActionButton:純圖標(biāo),F(xiàn)AB 用于頁面主操作。
  • 三要素:child(內(nèi)容)、onPressed(回調(diào))、style(主題)。回調(diào)為 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 = ['全部', '進(jìn)行中', '已完成', '已取消'];

  @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('我同意服務(wù)條款'),
          value: acceptTerms,
          onChanged: (value) => setState(() => acceptTerms = value!),
          controlAffinity: ListTileControlAffinity.leading,
        ),
        
        // Switch
        SwitchListTile(
          title: const Text('啟用通知'),
          subtitle: const Text('接收應(yīng)用推送通知'),
          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!),
        ),
      ],
    );
  }
}

輸入狀態(tài)管理

TextEditingController

  • 管理輸入文本、監(jiān)聽變化,記得在 dispose() 中釋放。
  • 可通過 addListener() 實(shí)現(xiàn)實(shí)時同步。

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('提交'),
      ),
    ],
  ),
);

焦點(diǎn)與鍵盤控制

FocusNode 基礎(chǔ)

  • FocusNode 用于管理輸入控件的焦點(diǎn)狀態(tài)。
  • 需要在 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: '用戶名'),
    );
  }
}

焦點(diǎn)控制

  • 請求焦點(diǎn)_focusNode.requestFocus()FocusScope.of(context).requestFocus(_focusNode)
  • 取消焦點(diǎn)_focusNode.unfocus()FocusScope.of(context).unfocus()
  • 監(jiān)聽焦點(diǎn)變化_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 鍵的焦點(diǎn)遍歷順序。
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'
                '結(jié)束: ${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('選擇日期范圍'),
        ),
      ],
    );
  }
}

完整示例:預(yù)約表單

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('預(yù)約時間: $appointment')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text('預(yù)約服務(wù)', 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('確認(rèn)預(yù)約'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

滑動與手勢交互

Dismissible

  • 通過滑動刪除列表項(xiàng)。
  • 必須提供唯一 key
  • background / secondaryBackground 自定義效果。
  • direction 控制滑動方向。
  • confirmDismiss 可以在刪除前彈出確認(rèn)對話框。
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('確認(rèn)刪除'),
        content: const Text('確定要刪除這一項(xiàng)嗎?'),
        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

  • 自定義點(diǎn)擊、拖拽、長按等手勢。
  • 常用回調(diào):
    • 點(diǎn)擊onTap、onDoubleTap、onLongPress
    • 拖拽onPanStart、onPanUpdateonPanEnd
    • 縮放onScaleStart、onScaleUpdateonScaleEnd
    • 垂直/水平拖動onVerticalDragUpdate、onHorizontalDragUpdate
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 風(fēng)格的水波紋效果。
  • InkWell 填充整個區(qū)域,InkResponse 僅響應(yīng)點(diǎn)擊區(qū)域。
InkWell(
  onTap: () => debugPrint('點(diǎn)擊'),
  onLongPress: () => debugPrint('長按'),
  borderRadius: BorderRadius.circular(8),
  splashColor: Colors.blue.withOpacity(0.3),
  child: Container(
    padding: const EdgeInsets.all(16),
    child: const Text('點(diǎn)擊我'),
  ),
);

Draggable 與 DragTarget

  • 實(shí)現(xiàn)拖放功能。
// 可拖動的元素
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('拖動我')),
  ),
);

// 拖放目標(biāo)
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 為自定義控件添加語義信息。
  • 確保所有交互元素都有合適的標(biāo)簽和提示。
Semantics(
  label: '提交按鈕',
  hint: '點(diǎn)擊提交表單',
  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 測試基礎(chǔ)

import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('測試文本輸入', (WidgetTester tester) async {
    // 構(gòu)建 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();

    // 驗(yàn)證文本
    expect(find.text('test_user'), findsOneWidget);
  });

  testWidgets('測試按鈕點(diǎn)擊', (WidgetTester tester) async {
    int counter = 0;

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: StatefulBuilder(
            builder: (context, setState) {
              return ElevatedButton(
                onPressed: () => setState(() => counter++),
                child: Text('點(diǎn)擊次數(shù): $counter'),
              );
            },
          ),
        ),
      ),
    );

    // 點(diǎn)擊按鈕
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump();

    // 驗(yàn)證結(jié)果
    expect(find.text('點(diǎn)擊次數(shù): 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'),
            ),
          ),
        ),
      ),
    );

    // 驗(yàn)證初始狀態(tài)
    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();

    // 驗(yàn)證滑動后的狀態(tài)
    expect(find.text('Item 0'), findsNothing);
    expect(find.text('Item 50'), findsOneWidget);
  });

  testWidgets('測試表單驗(yàn)證', (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();

    // 驗(yàn)證錯誤信息
    expect(find.text('請輸入有效的郵箱'), findsOneWidget);

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

    // 驗(yàn)證錯誤信息消失
    expect(find.text('請輸入有效的郵箱'), findsNothing);
  });
}

高級技巧與最佳實(shí)踐

輸入防抖與節(jié)流

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(() {
      // 執(zhí)行搜索
      debugPrint('搜索: $query');
    });
  }

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

輸入格式化

import 'package:flutter/services.dart';

// 手機(jī)號格式化
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: '手機(jī)號',
    hintText: '138 0000 0000',
  ),
);

表單狀態(tài)保存與恢復(fù)

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,
);

練習(xí)建議

初級練習(xí)

  1. 登錄表單:創(chuàng)建包含用戶名、密碼輸入框和登錄按鈕的表單,添加基本驗(yàn)證。
  2. 計(jì)數(shù)器應(yīng)用:使用不同類型的按鈕實(shí)現(xiàn)加減功能。
  3. 待辦列表:實(shí)現(xiàn)添加、刪除待辦事項(xiàng),使用 Dismissible 滑動刪除。

中級練習(xí)

  1. 多步驟注冊表單:包含驗(yàn)證、焦點(diǎn)管理、進(jìn)度指示。
  2. 搜索功能:實(shí)現(xiàn)帶防抖的實(shí)時搜索,顯示搜索結(jié)果。
  3. 設(shè)置頁面:使用各種輸入控件(Switch、Slider、DropdownMenu 等)。
  4. 日期范圍選擇器:選擇開始和結(jié)束日期,驗(yàn)證日期有效性。

高級練習(xí)

  1. 自定義表單控件:創(chuàng)建可復(fù)用的自定義輸入組件,支持驗(yàn)證和格式化。
  2. 拖放排序列表:實(shí)現(xiàn)可拖動排序的列表。
  3. 富文本編輯器:支持文本格式化、插入圖片等功能。
  4. 手勢識別游戲:使用 GestureDetector 實(shí)現(xiàn)滑動、縮放等交互。
  5. 完整的表單測試套件:為復(fù)雜表單編寫全面的 Widget 測試。

實(shí)戰(zhàn)項(xiàng)目

  1. 問卷調(diào)查應(yīng)用:支持多種題型(單選、多選、填空、評分)。
  2. 筆記應(yīng)用:支持富文本編輯、標(biāo)簽管理、搜索過濾。
  3. 電商購物車:商品數(shù)量調(diào)整、滑動刪除、優(yōu)惠券輸入。
  4. 社交媒體發(fā)布:文本輸入、圖片上傳、話題標(biāo)簽、@提及功能。

常見問題與解決方案

鍵盤遮擋輸入框

Scaffold(
  resizeToAvoidBottomInset: true, // 默認(rèn)為 true
  body: SingleChildScrollView(
    child: Padding(
      padding: EdgeInsets.only(
        bottom: MediaQuery.of(context).viewInsets.bottom,
      ),
      child: YourForm(),
    ),
  ),
);

TextField 性能優(yōu)化

// 對于大量 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); // 必須調(diào)用
    return TextField(/* ... */);
  }
}

監(jiān)聽輸入變化的最佳方式

// 方式 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} 個字符');
  },
);

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容