在这里插入图片描述

引言

发起组队是剧本杀组队App的核心功能之一,用户可以通过填写表单创建新的组队信息,邀请其他玩家加入。一个好的表单设计应该具备清晰的分区布局、直观的选择控件、友好的输入体验以及完善的表单验证。本篇将实现发起组队表单的完整功能,包括剧本选择器、店铺选择器、日期时间选择、人数滑块、价格滑块以及备注输入。通过本篇的学习,你将掌握Flutter中表单组件的使用、ChoiceChip选择器、Slider滑块控件以及日期时间选择器的应用。

功能需求分析

表单的核心功能

  1. 剧本选择:从列表中选择要玩的剧本
  2. 店铺选择:选择组队的店铺位置
  3. 日期时间选择:选择游戏的日期和时间
  4. 人数设置:设置组队的总人数
  5. 价格设置:设置每人的价格
  6. 备注输入:输入组队的备注说明
  7. 表单验证:验证所有必填项
  8. 提交功能:提交表单创建组队

用户交互需求

  • 用户可以方便地选择剧本和店铺
  • 用户可以选择游戏的日期和时间
  • 用户可以通过滑块调整人数和价格
  • 用户可以输入组队的备注说明
  • 用户可以提交表单创建组队

核心代码实现

第一部分:导入依赖与类定义

首先我们需要导入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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐