目錄
- 總體思路
-
基礎(chǔ)輸入 Widget
-
輸入狀態(tài)管理
- 焦點(diǎn)與鍵盤控制
- 日期與時間選擇
- 滑動與手勢交互
- 自定義交互與無障礙
- 測試用戶輸入
- 練習(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è)置文本。
-
decoration:InputDecoration(標(biāo)簽、提示、邊框、圖標(biāo))。
-
keyboardType:文本、數(shù)字、email 等鍵盤。
-
onChanged、onSubmitted:監(jiān)聽輸入。
-
maxLines/minLines,obscureText(密碼)、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)證邏輯。
- 重要屬性:
validator、onSaved、autovalidateMode。
- 和
Form 的 GlobalKey<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
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、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 風(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
// 可拖動的元素
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í)
-
登錄表單:創(chuàng)建包含用戶名、密碼輸入框和登錄按鈕的表單,添加基本驗(yàn)證。
-
計(jì)數(shù)器應(yīng)用:使用不同類型的按鈕實(shí)現(xiàn)加減功能。
-
待辦列表:實(shí)現(xiàn)添加、刪除待辦事項(xiàng),使用
Dismissible 滑動刪除。
中級練習(xí)
-
多步驟注冊表單:包含驗(yàn)證、焦點(diǎn)管理、進(jìn)度指示。
-
搜索功能:實(shí)現(xiàn)帶防抖的實(shí)時搜索,顯示搜索結(jié)果。
-
設(shè)置頁面:使用各種輸入控件(Switch、Slider、DropdownMenu 等)。
-
日期范圍選擇器:選擇開始和結(jié)束日期,驗(yàn)證日期有效性。
高級練習(xí)
-
自定義表單控件:創(chuàng)建可復(fù)用的自定義輸入組件,支持驗(yàn)證和格式化。
-
拖放排序列表:實(shí)現(xiàn)可拖動排序的列表。
-
富文本編輯器:支持文本格式化、插入圖片等功能。
-
手勢識別游戲:使用 GestureDetector 實(shí)現(xiàn)滑動、縮放等交互。
-
完整的表單測試套件:為復(fù)雜表單編寫全面的 Widget 測試。
實(shí)戰(zhàn)項(xiàng)目
-
問卷調(diào)查應(yīng)用:支持多種題型(單選、多選、填空、評分)。
-
筆記應(yīng)用:支持富文本編輯、標(biāo)簽管理、搜索過濾。
-
電商購物車:商品數(shù)量調(diào)整、滑動刪除、優(yōu)惠券輸入。
-
社交媒體發(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} 個字符');
},
);