目录
- 总体思路
-
基础输入 Widget
-
输入状态管理
- 焦点与键盘控制
- 日期与时间选择
- 滑动与手势交互
- 自定义交互与无障碍
- 测试用户输入
- 练习建议
总体思路
- Flutter 的输入处理是 声明式 的:UI 根据当前状态渲染,输入只需更新状态。
- 每个输入控件都可以搭配 控制器 / 回调 获取变化。
-
组合 是关键,复杂交互由多个基础 Widget 叠加实现。
基础输入 Widget
Text
-
Text 展示静态文本;若需要复制请选择 SelectableText,若需要局部样式则使用 RichText/TextSpan。参考文档
-
SelectableText 支持长按选择、复制;RichText 支持不同 TextSpan 样式并可嵌套点击事件。
TextField
- 最常见的单行/多行文本输入控件。
- 关键属性:
-
controller:读取/设置文本。
-
decoration:InputDecoration(标签、提示、边框、图标)。
-
keyboardType:文本、数字、email 等键盘。
-
onChanged、onSubmitted:监听输入。
-
maxLines/minLines,obscureText(密码)、readOnly。
- 示例:
final controller = TextEditingController();
TextField(
controller: controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Mascot Name',
),
onSubmitted: (value) => debugPrint('提交:$value'),
);
TextFormField
- 构建在
Form 之上,适合集成验证逻辑。
- 重要属性:
validator、onSaved、autovalidateMode。
- 和
Form 的 GlobalKey<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
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
- 自定义点击、拖拽、长按等手势。
- 常用回调:
-
点击:
onTap、onDoubleTap、onLongPress
-
拖拽:
onPanStart、onPanUpdate、onPanEnd
-
缩放:
onScaleStart、onScaleUpdate、onScaleEnd
-
垂直/水平拖动:
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 风格的水波纹效果。
-
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,
);
练习建议
初级练习
-
登录表单:创建包含用户名、密码输入框和登录按钮的表单,添加基本验证。
-
计数器应用:使用不同类型的按钮实现加减功能。
-
待办列表:实现添加、删除待办事项,使用
Dismissible 滑动删除。
中级练习
-
多步骤注册表单:包含验证、焦点管理、进度指示。
-
搜索功能:实现带防抖的实时搜索,显示搜索结果。
-
设置页面:使用各种输入控件(Switch、Slider、DropdownMenu 等)。
-
日期范围选择器:选择开始和结束日期,验证日期有效性。
高级练习
-
自定义表单控件:创建可复用的自定义输入组件,支持验证和格式化。
-
拖放排序列表:实现可拖动排序的列表。
-
富文本编辑器:支持文本格式化、插入图片等功能。
-
手势识别游戏:使用 GestureDetector 实现滑动、缩放等交互。
-
完整的表单测试套件:为复杂表单编写全面的 Widget 测试。
实战项目
-
问卷调查应用:支持多种题型(单选、多选、填空、评分)。
-
笔记应用:支持富文本编辑、标签管理、搜索过滤。
-
电商购物车:商品数量调整、滑动删除、优惠券输入。
-
社交媒体发布:文本输入、图片上传、话题标签、@提及功能。
常见问题与解决方案
键盘遮挡输入框
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} 个字符');
},
);