Flutter for OpenHarmony 剧本杀组队App实战:发起组队表单实现
摘要: 本文介绍了基于Flutter的剧本杀组队App中"发起组队"功能的实现方案。该功能通过表单形式收集用户组队信息,包括剧本选择、店铺选择、日期时间设置、人数和价格设定以及备注输入等核心功能。文章详细分析了用户交互需求,并提供了完整的代码实现方案,包括表单状态管理、ChoiceChip选择器、Slider滑块控件以及日期时间选择器的应用。实现过程中采用了Material D

引言
发起组队是剧本杀组队App的核心功能之一,用户可以通过填写表单创建新的组队信息,邀请其他玩家加入。一个好的表单设计应该具备清晰的分区布局、直观的选择控件、友好的输入体验以及完善的表单验证。本篇将实现发起组队表单的完整功能,包括剧本选择器、店铺选择器、日期时间选择、人数滑块、价格滑块以及备注输入。通过本篇的学习,你将掌握Flutter中表单组件的使用、ChoiceChip选择器、Slider滑块控件以及日期时间选择器的应用。
功能需求分析
表单的核心功能
- 剧本选择:从列表中选择要玩的剧本
- 店铺选择:选择组队的店铺位置
- 日期时间选择:选择游戏的日期和时间
- 人数设置:设置组队的总人数
- 价格设置:设置每人的价格
- 备注输入:输入组队的备注说明
- 表单验证:验证所有必填项
- 提交功能:提交表单创建组队
用户交互需求
- 用户可以方便地选择剧本和店铺
- 用户可以选择游戏的日期和时间
- 用户可以通过滑块调整人数和价格
- 用户可以输入组队的备注说明
- 用户可以提交表单创建组队
核心代码实现
第一部分:导入依赖与类定义
首先我们需要导入Flutter的Material组件库和GetX状态管理库。Material组件库提供了丰富的UI组件,GetX则用于路由导航和消息提示功能。
import 'package:flutter/material.dart';
import 'package:get/get.dart';
这两行导入语句是Flutter开发中最常见的依赖。material.dart包含了所有Material Design风格的组件,get.dart则是GetX框架的核心库,提供了状态管理、路由管理和依赖注入等功能。
接下来定义CreateTeamPage类,这是发起组队页面的主体结构。由于表单页面需要管理多个输入状态,我们选择使用StatefulWidget而不是StatelessWidget。
class CreateTeamPage extends StatefulWidget {
CreateTeamPage({super.key});
State<CreateTeamPage> createState() => _CreateTeamPageState();
}
StatefulWidget与StatelessWidget的区别在于前者可以维护可变状态。当用户在表单中进行选择或输入时,页面需要更新显示,这就需要使用StatefulWidget来管理这些状态变化。
下面是_CreateTeamPageState类的定义,包含了表单需要管理的所有状态变量。这些变量将存储用户的选择和输入,并在提交时用于创建组队信息。
class _CreateTeamPageState extends State<CreateTeamPage> {
final _formKey = GlobalKey<FormState>();
String _selectedScript = '';
String _selectedStore = '';
DateTime _selectedDate = DateTime.now();
TimeOfDay _selectedTime = TimeOfDay.now();
int _totalPlayers = 6;
double _price = 88;
String _description = '';
_formKey是一个GlobalKey类型的表单键,用于标识和操作Form组件。GlobalKey可以在Widget树中唯一标识一个Widget,通过它可以访问Form的状态并进行表单验证。在Flutter中,表单通常使用GlobalKey来管理,通过_formKey.currentState可以访问Form的状态,调用validate()方法可以验证所有表单字段,调用save()方法可以保存表单数据。
_selectedScript和_selectedStore分别存储用户选择的剧本和店铺名称,初始为空字符串表示未选择。_selectedDate使用DateTime.now()初始化为当前日期,_selectedTime使用TimeOfDay.now()初始化为当前时间。_totalPlayers默认为6人,这是剧本杀游戏中比较常见的人数配置。_price默认为88元,是一个比较合理的人均价格。
接下来定义可选项列表,这些数据在实际项目中应该从后端API获取,这里使用模拟数据进行演示。
final List<String> _scripts = [
'年轮',
'古木吟',
'你好',
'云使',
'白夜追凶',
'明星大侦探',
'密室逃脱',
'古墓迷踪',
];
final List<String> _stores = [
'迷雾剧本杀',
'探案馆',
'推理社',
'剧本杀工厂',
'谜题工坊',
'冒险岛',
];
_scripts列表包含了可选的剧本名称,涵盖了情感本、推理本、恐怖本等多种类型。_stores列表包含了可选的店铺名称,用户可以选择在哪家店铺进行游戏。这些列表数据使用final修饰,表示它们在初始化后不会被重新赋值。
第二部分:build方法与页面结构
build方法是Flutter Widget的核心方法,它返回页面的UI结构。每当状态发生变化时,Flutter会调用build方法重新构建UI。
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('发起组队'),
centerTitle: true,
elevation: 0,
backgroundColor: const Color(0xFF6B4EFF),
foregroundColor: Colors.white,
),
backgroundColor: const Color(0xFFF5F5F5),
Scaffold是Material Design的基础页面结构,它提供了AppBar、body、bottomNavigationBar等标准布局区域。AppBar设置了页面标题"发起组队",centerTitle: true让标题居中显示,elevation: 0去除阴影效果,backgroundColor设置为主题紫色,foregroundColor设置标题和图标为白色。页面背景色设置为浅灰色(0xFFF5F5F5),与白色卡片形成对比。
页面主体使用SingleChildScrollView实现滚动功能,这样当内容超过屏幕高度时用户可以滚动查看。Padding设置四周16像素的内边距,让内容不会贴边显示,提升视觉效果。
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('选择剧本'),
_buildScriptSelector(),
const SizedBox(height: 24),
_buildSectionTitle('选择店铺'),
_buildStoreSelector(),
const SizedBox(height: 24),
_buildSectionTitle('选择时间'),
_buildDateTimeSelector(),
const SizedBox(height: 24),
_buildSectionTitle('设置人数'),
_buildPlayerCountSlider(),
const SizedBox(height: 24),
_buildSectionTitle('设置价格'),
_buildPriceSlider(),
const SizedBox(height: 24),
_buildSectionTitle('组队说明'),
_buildDescriptionInput(),
const SizedBox(height: 32),
_buildSubmitButton(),
const SizedBox(height: 16),
],
),
),
),
),
);
}
Form组件包裹所有表单字段,key属性绑定_formKey用于表单验证。Column垂直排列各个表单部分,crossAxisAlignment设为start左对齐。每个表单部分之间使用SizedBox添加24像素的间距,提交按钮上方使用32像素的间距,让按钮与表单内容有更明显的分隔。
_buildSectionTitle方法用于构建每个表单部分的标题,这种分区设计让表单结构清晰,用户可以快速找到所需的输入项。
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF6B4EFF),
),
),
);
}
标题使用16号粗体紫色文字,下方12像素间距与内容分隔。紫色与AppBar颜色一致,保持视觉统一。fontWeight.bold让标题更加醒目,帮助用户快速定位表单的各个部分。
第三部分:剧本选择器
剧本选择器使用Wrap布局和ChoiceChip组件实现单选功能。Wrap布局可以自动换行,当一行放不下所有选项时会自动换到下一行。
Widget _buildScriptSelector() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: _scripts.map((script) {
bool isSelected = _selectedScript == script;
return ChoiceChip(
label: Text(script),
selected: isSelected,
onSelected: (selected) {
setState(() => _selectedScript = selected ? script : '');
},
selectedColor: const Color(0xFF6B4EFF),
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
),
);
}).toList(),
);
}
Wrap组件的spacing属性设置水平间距为8像素,runSpacing设置行间距为8像素。_scripts.map()方法遍历剧本列表,为每个剧本创建一个ChoiceChip组件。
ChoiceChip是Material Design中用于单选的芯片组件,类似于RadioButton但外观更现代。selected属性表示是否选中,通过比较_selectedScript与当前剧本名称来判断。onSelected回调在点击时更新_selectedScript状态,如果点击已选中的选项则取消选择(设为空字符串)。selectedColor设置选中时的背景色为主题紫色,labelStyle根据是否选中设置文字颜色,选中时为白色,未选中时为黑色。
这种设计让用户可以一眼看到所有可选的剧本,并快速选择。相比下拉选择器,ChoiceChip的优势在于所有选项都直接展示,用户不需要额外的点击操作就能看到所有选项。
第四部分:店铺选择器
店铺选择器的实现与剧本选择器完全相同,只是数据源不同。使用相同的ChoiceChip组件和Wrap布局,确保整个表单的视觉风格一致。
Widget _buildStoreSelector() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: _stores.map((store) {
bool isSelected = _selectedStore == store;
return ChoiceChip(
label: Text(store),
selected: isSelected,
onSelected: (selected) {
setState(() => _selectedStore = selected ? store : '');
},
selectedColor: const Color(0xFF6B4EFF),
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
),
);
}).toList(),
);
}
代码结构与剧本选择器完全一致,这种一致性不仅让代码更易于维护,也让用户在使用时有统一的体验。用户学会了如何选择剧本后,自然就知道如何选择店铺。
在实际项目中,可以考虑将这个选择器抽取为一个通用组件,接收选项列表和选中值作为参数,减少代码重复。
第五部分:日期时间选择器
日期时间选择器是表单中比较复杂的部分,需要同时处理日期和时间两个维度的选择。我们使用Flutter内置的showDatePicker和showTimePicker来实现。
Widget _buildDateTimeSelector() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: InkWell(
onTap: () => _selectDate(context),
child: Row(
children: [
const Icon(Icons.calendar_today, color: Color(0xFF6B4EFF)),
const SizedBox(width: 8),
Text(
'${_selectedDate.year}-${_selectedDate.month.toString().padLeft(2, '0')}-${_selectedDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 14),
),
],
),
),
),
Container使用白色背景和8像素圆角,与页面灰色背景形成对比。内部使用Row横向排列日期和时间两个选择项,每个选择项使用Expanded平分空间。
InkWell组件包裹日期显示区域,提供点击效果和点击回调。点击时调用_selectDate方法打开系统的日期选择器。日期显示使用格式化的字符串,padLeft(2, ‘0’)方法确保月、日都是两位数字,例如"2024-01-05"而不是"2024-1-5"。
Expanded(
child: InkWell(
onTap: () => _selectTime(context),
child: Row(
children: [
const Icon(Icons.access_time, color: Color(0xFF6B4EFF)),
const SizedBox(width: 8),
Text(
'${_selectedTime.hour.toString().padLeft(2, '0')}:${_selectedTime.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 14),
),
],
),
),
),
],
),
],
),
);
}
时间选择区域的实现与日期类似,使用access_time图标表示时间,点击时调用_selectTime方法。时间格式化同样使用padLeft确保小时和分钟都是两位数字。
下面是日期选择和时间选择的具体实现方法:
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null && picked != _selectedDate) {
setState(() => _selectedDate = picked);
}
}
_selectDate方法使用showDatePicker显示系统的日期选择器。这是一个异步方法,使用async/await语法等待用户选择。initialDate设为当前选中的日期,firstDate设为今天(不能选择过去的日期),lastDate设为30天后(限制只能选择未来30天内的日期)。
如果用户选择了日期(picked不为null)且与当前选中日期不同,则调用setState更新_selectedDate状态,触发UI重建。
Future<void> _selectTime(BuildContext context) async {
final TimeOfDay? picked = await showTimePicker(
context: context,
initialTime: _selectedTime,
);
if (picked != null && picked != _selectedTime) {
setState(() => _selectedTime = picked);
}
}
_selectTime方法使用showTimePicker显示系统的时间选择器。TimeOfDay是Flutter中表示时间的类,只包含小时和分钟,不包含日期信息。选择逻辑与日期选择类似。
第六部分:人数滑块
人数滑块让用户可以直观地调整组队人数。相比输入框,滑块的优势在于用户可以快速调整数值,同时限制了输入范围,避免无效输入。
Widget _buildPlayerCountSlider() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('总人数'),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF6B4EFF).withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: Text(
'$_totalPlayers 人',
style: const TextStyle(
color: Color(0xFF6B4EFF),
fontWeight: FontWeight.bold,
),
),
),
],
),
Container使用白色背景和圆角,与其他表单区域保持一致。上方Row显示"总人数"标签和当前人数值。人数值使用胶囊形状的标签显示,浅紫色背景配合紫色粗体文字,让数值更加醒目。
mainAxisAlignment.spaceBetween让标签和数值分别靠左和靠右对齐,充分利用水平空间。
const SizedBox(height: 12),
Slider(
value: _totalPlayers.toDouble(),
min: 2,
max: 12,
divisions: 10,
label: '$_totalPlayers',
onChanged: (value) {
setState(() => _totalPlayers = value.toInt());
},
activeColor: const Color(0xFF6B4EFF),
),
],
),
);
}
Slider组件是Flutter提供的滑块控件。value属性需要double类型,所以使用toDouble()转换。min设为2(最少2人才能组队),max设为12(剧本杀游戏通常不超过12人)。
divisions设为10表示滑块有10个刻度,用户只能选择2、3、4…12这些整数值。label属性在用户拖动滑块时显示当前值的气泡提示。onChanged回调在滑块移动时更新_totalPlayers状态,使用toInt()转换回整数。activeColor设置滑块已选择部分的颜色为主题紫色。
第七部分:价格滑块
价格滑块的实现与人数滑块类似,但数值范围和精度不同。价格通常需要更细的调整粒度。
Widget _buildPriceSlider() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('人均价格'),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF6B4EFF).withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
价格滑块的min设为50元,max设为200元,这是剧本杀游戏的常见价格区间。divisions设为30,意味着每个刻度代表5元的变化,用户可以选择50、55、60…200这些价格。
child: Text(
'¥${_price.toInt()}',
style: const TextStyle(
color: Color(0xFF6B4EFF),
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
Slider(
value: _price,
min: 50,
max: 200,
divisions: 30,
label: '¥${_price.toInt()}',
onChanged: (value) {
setState(() => _price = value);
},
activeColor: const Color(0xFF6B4EFF),
),
],
),
);
}
价格显示使用人民币符号"¥"前缀,toInt()将浮点数转换为整数显示,避免出现小数点。在实际项目中,如果需要支持小数价格,可以使用toStringAsFixed(2)格式化为两位小数。
第八部分:备注输入
备注输入使用TextFormField组件,允许用户输入多行文本说明组队的具体要求或注意事项。
Widget _buildDescriptionInput() {
return TextFormField(
maxLines: 4,
decoration: InputDecoration(
hintText: '输入组队说明(可选)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF6B4EFF)),
),
),
onChanged: (value) => _description = value,
);
}
TextFormField是Flutter中用于表单输入的组件,相比TextField它提供了更多的表单验证功能。maxLines设为4允许多行输入,输入框会显示4行的高度。
decoration配置输入框的样式。hintText显示占位符文字"输入组队说明(可选)",提示用户这是一个可选字段。border设置默认边框样式为圆角矩形。focusedBorder设置获得焦点时的边框样式,使用紫色边框提示用户当前正在编辑这个字段。
onChanged回调在输入内容变化时更新_description状态。注意这里没有使用setState,因为我们只需要在提交时获取最终值,不需要实时更新UI。
第九部分:提交按钮
提交按钮是表单的最后一个组件,用户点击后会验证表单并提交数据。按钮设计要醒目,让用户容易找到。
Widget _buildSubmitButton() {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6B4EFF),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'发起组队',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
}
SizedBox的width设为double.infinity让按钮占满整个宽度,这是移动端常见的按钮设计。ElevatedButton是Material Design的凸起按钮,有阴影效果。
onPressed回调调用_submitForm方法进行表单验证和提交。style配置按钮样式:backgroundColor设为主题紫色,padding设置垂直内边距14像素让按钮更高,shape设置圆角8像素与其他组件保持一致。
按钮文字使用白色16号粗体,在紫色背景上清晰可见。"发起组队"这个文案明确告诉用户点击后会发生什么。
第十部分:表单提交逻辑
表单提交方法负责验证用户输入并处理提交逻辑。在实际项目中,这里应该调用API将数据发送到服务器。
void _submitForm() {
if (_selectedScript.isEmpty) {
Get.snackbar('提示', '请选择剧本');
return;
}
if (_selectedStore.isEmpty) {
Get.snackbar('提示', '请选择店铺');
return;
}
Get.snackbar(
'成功',
'组队已发起!',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.green,
colorText: Colors.white,
);
Future.delayed(const Duration(seconds: 1), () {
Get.back();
});
}
}
_submitForm方法首先验证必填项。如果剧本未选择,显示提示"请选择剧本"并返回,不继续执行。如果店铺未选择,同样显示提示并返回。这种逐项验证的方式可以让用户明确知道哪个字段需要填写。
Get.snackbar是GetX提供的消息提示方法,比Flutter原生的SnackBar更易用。snackPosition设为BOTTOM让提示显示在屏幕底部,backgroundColor设为绿色表示成功,colorText设为白色确保文字可见。
验证通过后显示成功提示,1秒后调用Get.back()返回上一页。Future.delayed用于延迟执行,让用户有时间看到成功提示。在实际项目中,这里应该先调用API创建组队,成功后再返回。
表单设计要点总结
1. 分区布局的重要性
将表单分为多个逻辑区域,每个区域有标题和相关的输入控件。这样可以让表单结构清晰,用户可以快速找到所需的输入项。分区布局还有助于用户理解表单的填写流程,从上到下依次填写各个部分。
2. 多种输入控件的选择
根据数据类型选择合适的输入控件:
- ChoiceChip适合有限选项的单选场景
- Slider适合数值范围调整
- TextFormField适合自由文本输入
- DatePicker和TimePicker适合日期时间选择
多种控件的组合可以提供更好的用户体验,避免所有字段都使用文本输入框的单调感。
3. 实时反馈的价值
在用户进行操作时立即显示结果,如滑块移动时立即显示新的数值。这样可以让用户清楚地知道自己的操作效果,减少不确定感。
4. 表单验证的必要性
在提交前验证必填项,确保数据的完整性。验证失败时给出明确的提示,告诉用户需要修正什么。
完整代码
将上述所有代码整合,完整的发起组队表单页面代码结构如下:
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class CreateTeamPage extends StatefulWidget {
// ... 类定义
}
class _CreateTeamPageState extends State<CreateTeamPage> {
// 状态变量定义
// build方法
// _buildSectionTitle方法
// _buildScriptSelector方法
// _buildStoreSelector方法
// _buildDateTimeSelector方法
// _selectDate方法
// _selectTime方法
// _buildPlayerCountSlider方法
// _buildPriceSlider方法
// _buildDescriptionInput方法
// _buildSubmitButton方法
// _submitForm方法
}
扩展功能建议
1. 表单数据持久化
使用SharedPreferences或本地数据库保存用户的草稿,避免意外退出导致数据丢失。
2. 图片上传
允许用户上传剧本封面或店铺照片,让组队信息更加丰富。
3. 位置选择
集成地图SDK,让用户可以在地图上选择店铺位置,或者自动获取当前位置。
4. 表单模板
保存用户常用的表单配置作为模板,下次发起组队时可以快速填充。
总结
通过本篇文章的学习,我们完成了发起组队表单的实现。这个表单提供了完整的组队信息输入功能,用户可以方便地创建新的组队。
表单设计遵循了Material Design的规范,使用多种输入控件提供丰富的交互体验,同时保持界面的整洁和易用性。代码结构清晰,每个功能模块都封装为独立的方法,便于维护和扩展。
下一篇文章我们将实现组队详情展示功能,敬请期待!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)