在这里插入图片描述

组建一个好的队伍对游戏成功很重要。这个模块我一般会先把“可用队友列表 + 选择 + 邀请反馈”做成一个最小闭环,跑通之后再慢慢加细节(上限、空态、可维护性拆分)。

下面的实现会尽量按真实项目写法去组织:每段代码都不长,并且每段后面紧跟一段说明,方便你直接搬到工程里改。

效果预览

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这一张图能看出两个关键点

  • 入口明确:首页“队友匹配”是一个独立功能卡片。
  • 风格统一:深色背景 + 高亮按钮颜色,这类 UI 在游戏工具 App 里比较常见。

队友数据模型(先把字段定死)

class Player {
  final String name;
  final int level;
  final double winRate;
  final double kd;
  final String role;
  final String status;

  const Player({
    required this.name,
    required this.level,
    required this.winRate,
    required this.kd,
    required this.role,
    required this.status,
  });
}

这段模型主要在干什么

  • 字段集中:页面展示要用到的信息,模型里先收敛好,后面换数据源(接口/缓存)都更顺。
  • 类型固定winRatekddouble,避免 UI 层再做乱七八糟的转换。

mock 数据(先让页面跑起来)

class TeamMatcher {
  static final List<Player> availablePlayers = [
    Player(
      name: '职业选手',
      level: 50,
      winRate: 35.5,
      kd: 4.2,
      role: '突击手',
      status: '在线',
    ),
    Player(
      name: '狙击高手',
      level: 48,
      winRate: 28.3,
      kd: 3.8,
      role: '狙击手',
      status: '在线',
    ),
  ];
}

为什么我倾向于单独放一个“数据提供者”

  • 页面更干净:UI 不直接 new 数据,后期你把它替换成接口调用时,改动面更小。
  • 便于做筛选:比如“只看在线”“按定位过滤”,都可以先在这里处理。

页面骨架(先拆出列表和底部操作区)

class TeamMatcherPage extends StatefulWidget {
  const TeamMatcherPage({Key? key}) : super(key: key);

  
  State<TeamMatcherPage> createState() => _TeamMatcherPageState();
}

这里没什么花活

  • 保持简单:这个页面内部就能完成选择状态维护,先不引入额外状态管理,便于你理解。
class _TeamMatcherPageState extends State<TeamMatcherPage> {
  static const int _maxSelect = 3;
  final Set<int> _selectedPlayers = <int>{};

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('队友匹配'),
        backgroundColor: const Color(0xFF2D2D2D),
      ),
      backgroundColor: const Color(0xFF1A1A1A),
      body: Column(
        children: [
          Expanded(child: _buildPlayerList()),
          _buildInviteBar(),
        ],
      ),
    );
  }
}

骨架这么拆的好处

  • 结构稳:列表永远占满剩余高度,底部按钮区按需显示。
  • 后期易加功能:比如加搜索框、筛选条,只需要往 Column 里插一段即可。

列表渲染(只负责把数据变成卡片)

Widget _buildPlayerList() {
  return ListView.builder(
    padding: EdgeInsets.all(16.w),
    itemCount: TeamMatcher.availablePlayers.length,
    itemBuilder: (context, index) {
      return _buildPlayerCard(TeamMatcher.availablePlayers[index], index);
    },
  );
}

这段刻意“很薄”

  • 不写业务:只做循环渲染,不在 itemBuilder 里塞选择逻辑。
  • 方便替换数据源:以后换成分页、下拉刷新也好改。

选择逻辑(带上限 + 反馈)

void _toggleSelect(int index) {
  final isSelected = _selectedPlayers.contains(index);

  if (!isSelected && _selectedPlayers.length >= _maxSelect) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('最多只能选择 3 位队友')),
    );
    return;
  }

  setState(() {
    if (isSelected) {
      _selectedPlayers.remove(index);
    } else {
      _selectedPlayers.add(index);
    }
  });
}

真实项目里这个点很常见

  • 上限约束要有提示:点了没反应是最差体验,SnackBar 至少让用户知道原因。
  • 集合比 List 合适Set<int> 天然去重,不怕重复选中。

队友卡片(信息展示 + 右侧操作)

Widget _buildPlayerCard(Player player, int index) {
  final isSelected = _selectedPlayers.contains(index);

  return Card(
    margin: EdgeInsets.only(bottom: 12.h),
    color: const Color(0xFF2D2D2D),
    child: Padding(
      padding: EdgeInsets.all(16.w),
      child: Row(
        children: [
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Row(
                  children: [
                    Text(
                      player.name,
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 14.sp,
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    SizedBox(width: 8.w),
                    _buildRoleTag(player.role),
                  ],
                ),
                SizedBox(height: 8.h),
                Text(
                  '等级: ${player.level}  胜率: ${player.winRate}%  K/D: ${player.kd}',
                  style: TextStyle(color: Colors.white70, fontSize: 11.sp),
                ),
              ],
            ),
          ),
          _buildSelectButton(isSelected, () => _toggleSelect(index)),
        ],
      ),
    ),
  );
}

为什么把“操作”放到右侧

  • 滑动更稳定:列表滑动时整卡可点击容易误触;右侧按钮区更明确。
  • 状态更直观:按钮变色 + 图标变化,用户几乎不用读文字。

角色标签(小组件,小而有用)

Widget _buildRoleTag(String role) {
  return Container(
    padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h),
    decoration: BoxDecoration(
      color: const Color(0xFF4CAF50).withOpacity(0.2),
      borderRadius: BorderRadius.circular(4.r),
    ),
    child: Text(
      role,
      style: TextStyle(
        color: const Color(0xFF4CAF50),
        fontSize: 10.sp,
        fontWeight: FontWeight.bold,
      ),
    ),
  );
}

这段属于“看起来小,但很值”的封装

  • 统一风格:标签样式以后想改只改一个地方。
  • 减少嵌套:卡片内部少几层 Container,读起来轻松很多。

选择按钮(封装点击区域与视觉状态)

Widget _buildSelectButton(bool isSelected, VoidCallback onTap) {
  return GestureDetector(
    onTap: onTap,
    child: Container(
      width: 40.w,
      height: 40.w,
      decoration: BoxDecoration(
        color: isSelected ? const Color(0xFFFF6B35) : Colors.white10,
        borderRadius: BorderRadius.circular(20.r),
      ),
      child: Icon(
        isSelected ? Icons.check : Icons.add,
        color: Colors.white,
        size: 20.sp,
      ),
    ),
  );
}

这里我一般会关注两件事

  • 触达面积:40x40 基本够用,太小会难点。
  • 状态反馈:颜色 + 图标同时变化,弱光环境也看得清。

邀请按钮区(不选就不显示)

Widget _buildInviteBar() {
  if (_selectedPlayers.isEmpty) {
    return const SizedBox.shrink();
  }

  return Padding(
    padding: EdgeInsets.all(16.w),
    child: ElevatedButton(
      onPressed: _inviteSelected,
      style: ElevatedButton.styleFrom(
        backgroundColor: const Color(0xFFFF6B35),
        minimumSize: Size(double.infinity, 50.h),
      ),
      child: Text(
        '邀请 ${_selectedPlayers.length} 位队友',
        style: TextStyle(
          color: Colors.white,
          fontSize: 14.sp,
          fontWeight: FontWeight.bold,
        ),
      ),
    ),
  );
}

这种写法比较省心

  • 无选择时不占位置SizedBox.shrink() 不会撑起高度。
  • 文案带数量:用户知道自己当前选了几个,不用再看别处。

邀请动作(先做反馈,后面再接接口)

void _inviteSelected() {
  final names = _selectedPlayers
      .map((i) => TeamMatcher.availablePlayers[i].name)
      .join('、');

  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(content: Text('已向 $names 发送邀请')),
  );
}

为什么我不把逻辑直接写进 onPressed

  • 后面好扩展:接后端接口时要加 loading、失败提示、重试,都在这里改就行。
  • 页面更干净:UI 代码只负责“按下去调用什么”,不要夹杂业务细节。

小结

这个队友匹配页面做完,你会发现真正影响体验的不是控件有多少,而是这些细节:

  • 选择有上限也有提示:规则明确,用户就不会迷糊。
  • 代码结构能拆就拆:列表、卡片、按钮区分开,后面加功能不痛苦。
  • 先闭环再优化:先让“选人—邀请—反馈”跑通,之后再加筛选、搜索、接口接入。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

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

更多推荐