处理用户输入
既然您已经了解了如何在 Flutter 应用中管理状态,那么如何让用户与您的应用交互并更改其状态呢?
处理用户输入简介
#作为多平台 UI 框架,用户与 Flutter 应用交互的方式多种多样。本节中的资源将向您介绍一些用于启用应用内用户交互的常用 Widget。
参考:
接下来,我们将介绍一些 Material Widget,它们支持处理 Flutter 应用中常见用例的用户输入。
按钮
#按钮允许用户通过点击或轻触在 UI 中启动操作。Material 库提供了各种功能类似但样式不同的按钮类型,以满足各种用例,包括
ElevatedButton
:具有某些深度的按钮。使用凸起按钮为原本大部分平面的布局添加维度。FilledButton
:填充按钮,应用于完成流程的重要最终操作,例如**保存**、**立即加入**或**确认**。Tonal Button
:FilledButton
和OutlinedButton
之间的中间地带按钮。它们在以下情况下很有用:低优先级按钮需要比轮廓更突出的强调,例如**下一步**。OutlinedButton
:带有文本和可见边框的按钮。这些按钮包含重要的操作,但不是应用中的主要操作。TextButton
:可点击的文本,没有边框。由于文本按钮没有可见边框,因此必须依靠其相对于其他内容的位置来提供上下文。IconButton
:带有图标的按钮。FloatingActionButton
:悬停在内容上以促进主要操作的图标按钮。
视频:
构建按钮通常有三个主要方面:样式、回调及其子元素,如下面的 ElevatedButton
示例代码所示
按钮的回调函数
onPressed
确定点击按钮时会发生什么,因此,此函数是更新应用状态的地方。如果回调为null
,则按钮处于禁用状态,用户按下按钮时不会发生任何事情。按钮的
child
(显示在按钮内容区域内)通常是文本或图标,指示按钮的用途。最后,按钮的
style
控制其外观:颜色、边框等。
int count = 0;
@override
Widget build(BuildContext context) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
textStyle: const TextStyle(fontSize: 20),
),
onPressed: () {
setState(() {
count += 1;
});
},
child: const Text('Enabled'),
);
}

检查点:完成本教程,它将教你如何构建“收藏”按钮:
ElevatedButton
• FilledButton
• OutlinedButton
• TextButton
• IconButton
• FloatingActionButton
文本
#多个 Widget 支持文本输入。
SelectableText
#Flutter 的 Text
Widget 在屏幕上显示文本,但不允许用户突出显示或复制文本。SelectableText
显示一段用户可选的文本。
@override
Widget build(BuildContext context) {
return const SelectableText('''
Two households, both alike in dignity,
In fair Verona, where we lay our scene,
From ancient grudge break to new mutiny,
Where civil blood makes civil hands unclean.
From forth the fatal loins of these two foes''');
}

视频:
RichText
#RichText
允许您在应用中显示富文本字符串。TextSpan
与 RichText
类似,允许您以不同的文本样式显示文本的一部分。它不用于处理用户输入,但如果您允许用户编辑和格式化文本,则很有用。
@override
Widget build(BuildContext context) {
return RichText(
text: TextSpan(
text: 'Hello ',
style: DefaultTextStyle.of(context).style,
children: const <TextSpan>[
TextSpan(text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: ' world!'),
],
),
);
}

视频:
演示:
代码:
TextField
#TextField
允许用户使用硬件或屏幕键盘在文本框中输入文本。
TextField
具有许多不同的属性和配置。以下是一些亮点
InputDecoration
确定文本字段的外观,例如颜色和边框。controller
:TextEditingController
控制正在编辑的文本。您为什么可能需要控制器?默认情况下,您的应用用户可以键入文本字段,但如果您想以编程方式控制TextField
并清除其值(例如),则需要TextEditingController
。onChanged
:当用户更改文本字段的值时(例如,插入或删除文本时),此回调函数会触发。onSubmitted
:当用户指示他们已完成对字段中文本的编辑时触发此回调;例如,当文本字段处于焦点时点击“Enter”键。
该类支持其他可配置属性,例如 obscureText
,它会将每个字母在其输入时转换为 readOnly
圆圈,以及 readOnly
,它阻止用户更改文本。
final TextEditingController _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Mascot Name',
),
);
}

检查点:完成这套 4 部分的菜谱系列,它将引导您完成如何创建文本字段、检索其值以及更新应用状态的过程
表单
#Form
是一个可选容器,用于将多个表单字段 Widget(例如 TextField
)组合在一起。
每个单独的表单字段都应包装在 FormField
Widget 中,并将 Form
Widget 作为它们的共同祖先。存在预先将表单字段 Widget 包装在 FormField
中的便捷 Widget。例如,TextField
的 Form
Widget 版本是 TextFormField
。
使用 Form
可以访问 FormState
,它允许您保存、重置和验证从此 Form
派生的每个 FormField
。您还可以提供 GlobalKey
来识别特定表单,如下面的代码所示
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
TextFormField(
decoration: const InputDecoration(
hintText: 'Enter your email',
),
validator: (String? value) {
if (value == null || value.isEmpty) {
return 'Please enter some text';
}
return null;
},
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ElevatedButton(
onPressed: () {
// Validate returns true if the form is valid, or false otherwise.
if (_formKey.currentState!.validate()) {
// Process data.
}
},
child: const Text('Submit'),
),
),
],
),
);
}
检查点:完成本教程以了解如何
演示:
代码:
TextField
• RichText
• SelectableText
• Form
从一组选项中选择一个值
#为用户提供一种从多个选项中进行选择的方式。
SegmentedButton
#SegmentedButton
允许用户从最少 2-5 个项目的组中进行选择。
数据类型 <T>
可以是内置类型,例如 int
、String
、bool
或枚举。SegmentedButton
有一些相关的属性
segments
,一个ButtonSegment
列表,其中每个代表用户可以选择的一个“段”或选项。在视觉上,每个ButtonSegment
可以具有图标、文本标签或两者兼而有之。multiSelectionEnabled
指示用户是否允许选择多个选项。此属性默认为 false。selected
标识当前选定的值。**注意:**selected
的类型为Set<T>
,因此,如果您只允许用户选择一个值,则必须将其作为包含单个元素的Set
提供。当用户选择任何段时,
onSelectionChanged
回调会触发。它提供所选段的列表,以便您可以更新应用状态。其他样式参数允许您修改按钮的外观。例如,
style
采用ButtonStyle
,提供了一种配置selectedIcon
的方法。
enum Calendar { day, week, month, year }
// StatefulWidget...
Calendar calendarView = Calendar.day;
@override
Widget build(BuildContext context) {
return SegmentedButton<Calendar>(
segments: const <ButtonSegment<Calendar>>[
ButtonSegment<Calendar>(
value: Calendar.day,
label: Text('Day'),
icon: Icon(Icons.calendar_view_day)),
ButtonSegment<Calendar>(
value: Calendar.week,
label: Text('Week'),
icon: Icon(Icons.calendar_view_week)),
ButtonSegment<Calendar>(
value: Calendar.month,
label: Text('Month'),
icon: Icon(Icons.calendar_view_month)),
ButtonSegment<Calendar>(
value: Calendar.year,
label: Text('Year'),
icon: Icon(Icons.calendar_today)),
],
selected: <Calendar>{calendarView},
onSelectionChanged: (Set<Calendar> newSelection) {
setState(() {
Suggested change
// By default there is only a single segment that can be
// selected at one time, so its value is always the first
// By default, only a single segment can be
// selected at one time, so its value is always the first
calendarView = newSelection.first;
});
},
);
}

Chip
#Chip
是一种以紧凑的方式表示特定上下文中属性、文本、实体或操作的方法。针对特定用例存在专门的 Chip
Widget
- InputChip 以紧凑的形式表示复杂的信息,例如实体(人、地点或事物)或对话文本。
- ChoiceChip 允许从一组选项中进行单选。选择芯片包含相关的描述性文本或类别。
- FilterChip 使用标签或描述性词语来过滤内容。
- ActionChip 代表与主要内容相关的操作。
每个 Chip
小部件都需要一个 label
。它可以选择具有一个 avatar
(例如图标或用户的个人资料图片)和一个 onDeleted
回调函数,该回调函数会显示一个删除图标,当触发时,会删除该芯片。Chip
小部件的外观也可以通过设置一些可选参数(如 shape
、color
和 iconTheme
)来自定义。
您通常会使用 Wrap
(一个以多行水平或垂直方式显示其子元素的小部件)来确保您的芯片换行,并且不会在应用程序边缘被截断。
@override
Widget build(BuildContext context) {
return const SizedBox(
width: 500,
child: Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 4,
children: [
Chip(
avatar: CircleAvatar(
backgroundImage: AssetImage('assets/images/dash_chef.png')),
label: Text('Chef Dash'),
),
Chip(
avatar: CircleAvatar(
backgroundImage:
AssetImage('assets/images/dash_firefighter.png')),
label: Text('Firefighter Dash'),
),
Chip(
avatar: CircleAvatar(
backgroundImage: AssetImage('assets/images/dash_musician.png')),
label: Text('Musician Dash'),
),
Chip(
avatar: CircleAvatar(
backgroundImage: AssetImage('assets/images/dash_artist.png')),
label: Text('Artist Dash'),
),
],
),
);
}

DropdownMenu
#DropdownMenu
允许用户从选项菜单中选择一个选项,并将所选文本放置到 TextField
中。它还允许用户根据文本输入过滤菜单项。
配置参数包括以下内容
dropdownMenuEntries
提供了一个DropdownMenuEntry
列表,描述每个菜单项。菜单可能包含诸如文本标签以及引导或尾随图标之类的信息。(这也是唯一必需的参数。)TextEditingController
允许以编程方式控制TextField
。- 当用户选择一个选项时,
onSelected
回调函数会被触发。 initialSelection
允许您配置默认值。- 还提供了其他参数用于自定义小部件的外观和行为。
enum ColorLabel {
blue('Blue', Colors.blue),
pink('Pink', Colors.pink),
green('Green', Colors.green),
yellow('Orange', Colors.orange),
grey('Grey', Colors.grey);
const ColorLabel(this.label, this.color);
final String label;
final Color color;
}
// StatefulWidget...
@override
Widget build(BuildContext context) {
return DropdownMenu<ColorLabel>(
initialSelection: ColorLabel.green,
controller: colorController,
// requestFocusOnTap is enabled/disabled by platforms when it is null.
// On mobile platforms, this is false by default. Setting this to true will
// trigger focus request on the text field and virtual keyboard will appear
// afterward. On desktop platforms however, this defaults to true.
requestFocusOnTap: true,
label: const Text('Color'),
onSelected: (ColorLabel? color) {
setState(() {
selectedColor = color;
});
},
dropdownMenuEntries: ColorLabel.values
.map<DropdownMenuEntry<ColorLabel>>(
(ColorLabel color) {
return DropdownMenuEntry<ColorLabel>(
value: color,
label: color.label,
enabled: color.label != 'Grey',
style: MenuItemButton.styleFrom(
foregroundColor: color.color,
),
);
}).toList(),
);
}

视频:
滑块
#Slider
小部件允许用户通过移动指示器(例如音量条)来调整值。
Slider
小部件的配置参数
value
表示滑块的当前值onChanged
是在移动滑块手柄时触发的回调函数min
和max
设置滑块允许的最小值和最大值divisions
设置用户可以在轨道上移动手柄的离散间隔。
double _currentVolume = 1;
@override
Widget build(BuildContext context) {
return Slider(
value: _currentVolume,
max: 5,
divisions: 5,
label: _currentVolume.toString(),
onChanged: (double value) {
setState(() {
_currentVolume = value;
});
},
);
}

视频:
SegmentedButton
• DropdownMenu
• Slider
• Chip
在值之间切换
#您的 UI 可以通过多种方式允许在值之间切换。
复选框、开关和单选按钮
#提供一个选项来切换单个值的打开和关闭。这些小部件背后的功能逻辑是相同的,因为所有 3 个都是基于 ToggleableStateMixin
构建的,尽管每个小部件都提供了细微的呈现差异。
Checkbox
是一个容器,当为 false 时为空,当为 true 时填充一个复选标记。Switch
具有一个手柄,当为 false 时在左侧,当为 true 时滑动到右侧。Radio
与Checkbox
类似,它是一个容器,当为 false 时为空,但当为 true 时填充。
Checkbox
和 Switch
的配置包含
- 一个
value
,其值为true
或false
- 以及一个
onChanged
回调函数,当用户切换小部件时触发该回调函数
复选框
#bool isChecked = false;
@override
Widget build(BuildContext context) {
return Checkbox(
checkColor: Colors.white,
value: isChecked,
onChanged: (bool? value) {
setState(() {
isChecked = value!;
});
},
);
}

开关
#bool light = true;
@override
Widget build(BuildContext context) {
return Switch(
// This bool value toggles the switch.
value: light,
activeColor: Colors.red,
onChanged: (bool value) {
// This is called when the user toggles the switch.
setState(() {
light = value;
});
},
);
}

单选按钮
#一组 Radio
按钮,允许用户在互斥值之间进行选择。当用户在一组中选择一个单选按钮时,其他单选按钮将被取消选择。
- 特定
Radio
按钮的value
表示该按钮的值, - 一组单选按钮的选定值由
groupValue
参数标识。 Radio
还具有一个onChanged
回调函数,当用户点击它时触发,就像Switch
和Checkbox
一样
enum Character { musician, chef, firefighter, artist }
class RadioExample extends StatefulWidget {
const RadioExample({super.key});
@override
State<RadioExample> createState() => _RadioExampleState();
}
class _RadioExampleState extends State<RadioExample> {
Character? _character = Character.musician;
void setCharacter(Character? value) {
setState(() {
_character = value;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
ListTile(
title: const Text('Musician'),
leading: Radio<Character>(
value: Character.musician,
groupValue: _character,
onChanged: setCharacter,
),
),
ListTile(
title: const Text('Chef'),
leading: Radio<Character>(
value: Character.chef,
groupValue: _character,
onChanged: setCharacter,
),
),
ListTile(
title: const Text('Firefighter'),
leading: Radio<Character>(
value: Character.firefighter,
groupValue: _character,
onChanged: setCharacter,
),
),
ListTile(
title: const Text('Artist'),
leading: Radio<Character>(
value: Character.artist,
groupValue: _character,
onChanged: setCharacter,
),
),
],
);
}
}

额外:CheckboxListTile & SwitchListTile
#这些便捷的小部件与复选框和小开关小部件相同,但支持标签(作为 ListTile
)。
double timeDilation = 1.0;
bool _lights = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
CheckboxListTile(
title: const Text('Animate Slowly'),
value: timeDilation != 1.0,
onChanged: (bool? value) {
setState(() {
timeDilation = value! ? 10.0 : 1.0;
});
},
secondary: const Icon(Icons.hourglass_empty),
),
SwitchListTile(
title: const Text('Lights'),
value: _lights,
onChanged: (bool value) {
setState(() {
_lights = value;
});
},
secondary: const Icon(Icons.lightbulb_outline),
),
],
);
}

视频:
视频:
Checkbox
• CheckboxListTile
• Switch
• SwitchListTile
• Radio
选择日期或时间
#提供了一些小部件,以便用户可以选择日期和时间。
有一组对话框,允许用户选择日期或时间,您将在以下部分看到。除了不同的日期类型 - 日期为 DateTime
,时间为 TimeOfDay
之外,这些对话框的功能类似,您可以通过提供以下内容来配置它们
- 一个默认的
initialDate
或initialTime
- 或一个
initialEntryMode
,用于确定显示的选取器 UI。
DatePickerDialog
#此对话框允许用户选择日期或日期范围。通过调用 showDatePicker
函数激活,该函数返回一个 Future<DateTime>
,因此不要忘记等待异步函数调用!
DateTime? selectedDate;
@override
Widget build(BuildContext context) {
var date = selectedDate;
return Column(children: [
Text(
date == null
? "You haven't picked a date yet."
: DateFormat('MM-dd-yyyy').format(date),
),
ElevatedButton.icon(
icon: const Icon(Icons.calendar_today),
onPressed: () async {
var pickedDate = await showDatePicker(
context: context,
initialEntryMode: DatePickerEntryMode.calendarOnly,
initialDate: DateTime.now(),
firstDate: DateTime(2019),
lastDate: DateTime(2050),
);
setState(() {
selectedDate = pickedDate;
});
},
label: const Text('Pick a date'),
)
]);
}

TimePickerDialog
#TimePickerDialog
是一个显示时间选取器的对话框。可以通过调用 showTimePicker()
函数激活它。showTimePicker
不是返回 Future<DateTime>
,而是返回 Future<TimeOfDay>
。再次提醒,不要忘记等待函数调用!
TimeOfDay? selectedTime;
@override
Widget build(BuildContext context) {
var time = selectedTime;
return Column(children: [
Text(
time == null ? "You haven't picked a time yet." : time.format(context),
),
ElevatedButton.icon(
icon: const Icon(Icons.calendar_today),
onPressed: () async {
var pickedTime = await showTimePicker(
context: context,
initialEntryMode: TimePickerEntryMode.dial,
initialTime: TimeOfDay.now(),
);
setState(() {
selectedTime = pickedTime;
});
},
label: const Text('Pick a date'),
)
]);
}

showDatePicker
• showTimePicker
滑动和滑动手势
#Dismissible
是一个小部件,允许用户通过滑动来将其关闭。它有一些配置参数,包括
- 一个
child
小部件 - 一个
onDismissed
回调函数,当用户滑动时触发 - 样式参数,如
background
- 重要的是还要包含一个
key
对象,以便可以从窗口小部件树中同级Dismissible
小部件中唯一地识别它们。
List<int> items = List<int>.generate(100, (int index) => index);
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
padding: const EdgeInsets.symmetric(vertical: 16),
itemBuilder: (BuildContext context, int index) {
return Dismissible(
background: Container(
color: Colors.green,
),
key: ValueKey<int>(items[index]),
onDismissed: (DismissDirection direction) {
setState(() {
items.removeAt(index);
});
},
child: ListTile(
title: Text(
'Item ${items[index]}',
),
),
);
},
);
}

视频:
检查点:完成本教程,了解如何使用 dismissible 小部件
API 文档:
正在寻找更多 Widget?
#此页面仅介绍了一些您可以用于处理 Flutter 应用程序中用户输入的常见 Material 小部件。查看Material 小部件库和Material 库 API 文档以获取小部件的完整列表。
Material 3 演示,了解 Material 库中提供的用户输入小部件的精选示例。
演示:查看 Flutter 的
如果 Material 和 Cupertino 库没有您需要的小部件,请查看pub.dev以查找 Flutter 和 Dart 社区拥有和维护的包。例如,flutter_slidable
包提供了一个 Slidable
小部件,它比上一节中描述的 Dismissible
小部件更具可定制性。
视频:
使用 GestureDetector 构建交互式 Widget
#您是否已经仔细检查了小部件库、pub.dev,询问过您的编码朋友,但仍然找不到适合您要查找的用户交互的小部件?您可以构建自己的自定义小部件,并使用 GestureDetector
使其具有交互性。
处理点击。
检查点:使用此方法作为起点,创建您自己的自定义按钮小部件,该小部件可以
视频:
点击、拖动和其他手势,其中解释了如何在 Flutter 中侦听和响应手势。
参考:查看
额外视频:好奇 Flutter 的
GestureArena
如何将原始用户交互数据转换为人类可识别的概念(如点击、拖动和捏合)?查看此视频:GestureArena(解码 Flutter)
不要忘记辅助功能!
#如果您正在构建自定义小部件,请使用 Semantics
小部件对其含义进行注释。它为屏幕阅读器和其他基于语义分析的工具提供描述和元数据。
视频:
API 文档:
测试
#在您完成构建应用程序中的用户交互后,别忘了编写测试以确保一切按预期工作!
这些教程将指导您编写模拟应用程序中用户交互的测试。
点击、拖动和输入文本 食谱文章,了解如何使用
检查点: 按照此WidgetTester
模拟和测试应用程序中的用户交互。
处理滚动 食谱文章将向您展示如何通过使用小部件测试滚动列表来验证小部件列表是否包含预期的内容。
额外教程:
下一步:网络
#此页面介绍了如何处理用户输入。现在您已经了解了如何处理应用程序用户的输入,您可以通过添加外部数据使您的应用程序更有趣。在下一节中,您将学习如何通过网络获取应用程序数据、如何将数据转换为 JSON 格式以及反之、身份验证和其他网络功能。
反馈
#由于本网站的此部分正在不断发展,我们 欢迎您提供反馈!
除非另有说明,否则本网站上的文档反映了 Flutter 的最新稳定版本。页面上次更新时间:2024-09-26。 查看源代码 或 报告问题.