1. 项目概述与需求分析
在剧本杀组队App中,发起组队功能是核心交互场景之一。这个表单需要收集玩家组队所需的所有关键信息,包括剧本选择、店铺位置、游戏时间、参与人数、价格预算以及额外说明。作为Flutter for OpenHarmony系列教程的第四部分,本文将深入讲解如何构建一个完整、易用的组队表单。
表单设计面临几个关键挑战:首先,需要处理多种类型的数据输入(选择、滑动、文本);其次,要确保用户能够快速完成填写;最后,还需要考虑表单验证和错误处理。我们将使用Flutter的Material组件库来实现这些功能,同时保持与OpenHarmony系统的兼容性。
2. 表单结构与状态管理
2.1 页面基础结构
我们使用StatefulWidget来构建表单页面,因为表单需要维护多个可变状态。以下是页面基础结构的代码:
class CreateTeamPage extends StatefulWidget { const CreateTeamPage({super.key}); @override State<CreateTeamPage> createState() => _CreateTeamPageState(); } class _CreateTeamPageState extends State<CreateTeamPage> { final _formKey = GlobalKey<FormState>(); // 其他状态变量... @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('发起组队'), backgroundColor: const Color(0xFF6B4EFF), ), body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), child: Form( key: _formKey, child: Column( children: [ // 表单字段将在这里添加 ], ), ), ), ), ); } }GlobalKey 用于控制整个表单的状态,SingleChildScrollView确保在键盘弹出时表单可以滚动。每个表单字段之间使用SizedBox添加适当的间距,提升视觉层次感。
2.2 状态变量定义
表单需要管理以下状态变量:
String _selectedScript = ''; // 选择的剧本 String _selectedStore = ''; // 选择的店铺 DateTime _selectedDate = DateTime.now(); // 选择的日期 TimeOfDay _selectedTime = TimeOfDay.now(); // 选择的时间 int _totalPlayers = 6; // 玩家总数 double _price = 88; // 人均价格 String _description = ''; // 组队说明 // 可选剧本列表 final List<String> _scripts = [ '年轮', '古木吟', '你好', '云使', '白夜追凶', '明星大侦探' ]; // 可选店铺列表 final List<String> _stores = [ '迷雾剧本杀', '探案馆', '推理社', '剧本杀工厂', '谜题工坊' ];这些变量将随着用户操作而更新,并在提交表单时一起发送到服务器。初始值设置为常见的默认值,减少用户需要调整的次数。
3. 表单字段实现细节
3.1 剧本选择器实现
剧本选择使用Wrap和ChoiceChip组件实现多行排列的单选效果:
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和runSpacing分别控制水平和垂直间距。ChoiceChip的选中状态通过比较当前选项和_selectedScript的值来确定。当用户选择某个选项时,会更新_selectedScript状态并触发UI刷新。
3.2 日期时间选择器
日期和时间选择器组合在一个卡片中,使用系统的日期和时间选择对话框:
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), const SizedBox(width: 8), Text(_formatDate(_selectedDate)), ], ), ), ), Expanded( child: InkWell( onTap: () => _selectTime(context), child: Row( children: [ const Icon(Icons.access_time), const SizedBox(width: 8), Text(_formatTime(_selectedTime)), ], ), ), ), ], ), ], ), ); } String _formatDate(DateTime date) { return '${date.year}-${date.month.toString().padLeft(2,'0')}-${date.day.toString().padLeft(2,'0')}'; } String _formatTime(TimeOfDay time) { return '${time.hour.toString().padLeft(2,'0')}:${time.minute.toString().padLeft(2,'0')}'; }日期选择使用showDatePicker系统对话框,限制只能选择未来30天内的日期:
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); } }时间选择使用showTimePicker,允许用户选择任意时间:
Future<void> _selectTime(BuildContext context) async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: _selectedTime, ); if (picked != null && picked != _selectedTime) { setState(() => _selectedTime = picked); } }3.3 人数与价格滑块
人数滑块使用Slider组件,限制在2-12人范围内:
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('总人数'), Text('$_totalPlayers人', style: boldPurpleText), ], ), Slider( value: _totalPlayers.toDouble(), min: 2, max: 12, divisions: 10, label: '$_totalPlayers人', onChanged: (value) { setState(() => _totalPlayers = value.toInt()); }, ), ], ), ); }价格滑块实现类似,但数值范围和刻度更精细:
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('人均价格'), Text('¥${_price.toInt()}', style: boldPurpleText), ], ), Slider( value: _price, min: 50, max: 200, divisions: 30, label: '¥${_price.toInt()}', onChanged: (value) { setState(() => _price = value); }, ), ], ), ); }两个滑块都使用Container添加白色背景和圆角边框,保持与表单其他部分一致的视觉风格。
4. 表单验证与提交
4.1 必填字段验证
在提交表单前,我们需要验证用户是否已经填写了必填字段:
void _submitForm() { if (_selectedScript.isEmpty) { _showError('请选择剧本'); return; } if (_selectedStore.isEmpty) { _showError('请选择店铺'); return; } // 验证通过,处理提交逻辑 _handleSuccessfulSubmit(); } void _showError(String message) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: Colors.red, ) ); }验证失败时会显示红色的SnackBar提示用户。在实际应用中,你可能还需要添加更复杂的验证规则,比如检查日期是否合理、价格是否在可接受范围内等。
4.2 提交处理
验证通过后,我们可以将表单数据打包并发送到服务器:
void _handleSuccessfulSubmit() { final formData = { 'script': _selectedScript, 'store': _selectedStore, 'date': _selectedDate.toString(), 'time': '${_selectedTime.hour}:${_selectedTime.minute}', 'players': _totalPlayers, 'price': _price.toInt(), 'description': _description, }; // 这里应该是实际的API调用 print('提交的表单数据: $formData'); // 显示成功提示 _showSuccess(); } void _showSuccess() { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('组队已发起!'), backgroundColor: Colors.green, ) ); // 2秒后返回上一页 Future.delayed(const Duration(seconds: 2), () { Navigator.pop(context); }); }成功提交后显示绿色提示,并延迟返回上一页面,让用户有时间看到成功消息。
5. 样式优化与用户体验
5.1 统一视觉风格
为了保持一致的视觉风格,我们定义了几个常用的样式常量:
const boldPurpleText = TextStyle( color: Color(0xFF6B4EFF), fontWeight: FontWeight.bold, ); const sectionTitleStyle = TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF6B4EFF), );这些样式用于标题、数值显示等需要突出显示的元素。主色调使用紫色(#6B4EFF),与AppBar保持一致。
5.2 分区标题组件
每个表单部分都有一个标题,使用统一的样式:
Widget _buildSectionTitle(String title) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Text(title, style: sectionTitleStyle), ); }在build方法中使用:
Column( children: [ _buildSectionTitle('选择剧本'), _buildScriptSelector(), const SizedBox(height: 24), // 其他表单部分... ], )5.3 响应式布局考虑
为了适应不同尺寸的设备,我们需要注意以下几点:
- 使用百分比或弹性尺寸而不是固定像素值
- 确保表单在键盘弹出时仍然可滚动
- 在小屏幕上适当调整间距和字体大小
SingleChildScrollView已经确保了内容可滚动,其他元素使用相对单位如const SizedBox(height: 24)而不是固定像素值。
6. 完整代码结构
将所有部分组合起来,完整的表单页面代码如下:
import 'package:flutter/material.dart'; class CreateTeamPage extends StatefulWidget { const CreateTeamPage({super.key}); @override State<CreateTeamPage> createState() => _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 = ''; final List<String> _scripts = [ '年轮', '古木吟', '你好', '云使', '白夜追凶', '明星大侦探' ]; final List<String> _stores = [ '迷雾剧本杀', '探案馆', '推理社', '剧本杀工厂', '谜题工坊' ]; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('发起组队'), backgroundColor: const Color(0xFF6B4EFF), ), body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), child: Form( key: _formKey, child: Column( 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(), ], ), ), ), ), ); } // 所有前面定义的_build...方法放在这里 // _buildScriptSelector, _buildDateTimeSelector等... void _submitForm() { if (_selectedScript.isEmpty) { _showError('请选择剧本'); return; } if (_selectedStore.isEmpty) { _showError('请选择店铺'); return; } _handleSuccessfulSubmit(); } }7. 实际开发中的注意事项
在真实项目开发中,有几个关键点需要特别注意:
数据加载:剧本和店铺列表应该从后端API获取,而不是硬编码在前端。可以使用FutureBuilder或状态管理方案如Provider来处理异步数据加载。
表单持久化:考虑使用本地存储保存表单草稿,防止用户意外退出导致数据丢失。可以使用shared_preferences或hive等本地存储方案。
错误处理:网络请求需要完善的错误处理,包括超时、服务器错误等情况。显示适当的错误信息指导用户解决问题。
性能优化:对于复杂的表单,考虑将静态部分与动态部分分离,使用const构造函数减少不必要的重建。
国际化:如果应用需要支持多语言,所有字符串都应该放在arb文件中,使用Flutter的国际化支持。
测试覆盖:为表单编写单元测试和widget测试,确保各种交互场景都能正确处理。特别是边界条件,如最小/最大人数、价格等。
8. 扩展功能建议
基础表单完成后,可以考虑添加以下增强功能:
图片上传:允许用户上传剧本封面或店铺照片,丰富组队信息。
位置选择:集成地图SDK,让用户可以直接在地图上选择店铺位置。
表单模板:保存常用配置作为模板,下次可以快速填充。
邀请好友:在表单提交后直接跳转到分享界面,方便邀请好友加入。
实时验证:在用户输入时实时验证数据有效性,而不是等到提交时才检查。
自动填充:根据用户历史记录自动填充部分字段,减少输入工作量。
9. 跨平台兼容性考虑
由于这是Flutter for OpenHarmony项目,我们需要特别注意以下几点:
组件兼容性:确保使用的所有Flutter组件在OpenHarmony上都能正常工作。
性能表现:在真机上测试表单的响应速度,特别是动画和过渡效果。
平台特性:考虑利用OpenHarmony特有的能力来增强表单功能。
测试覆盖:在多种OpenHarmony设备上测试表单的显示和交互。
打包发布:遵循OpenHarmony的应用打包规范,确保表单功能在发布版本中正常工作。
通过本教程,我们完成了一个功能完整的剧本杀组队表单,涵盖了从UI构建到表单验证的完整流程。这个实现不仅适用于剧本杀App,也可以作为其他类型表单开发的参考模板。