Flutter for OpenHarmony 盲盒抽奖App应用实战+盲盒列表实现
盲盒列表页面这个页面其实挺重要的,因为用户想找特定类型的盲盒时,都会来这里。让用户能快速找到想要的盲盒,同时浏览体验要流畅。所以用了网格布局,配合分类筛选,这样既能展示足够多的盲盒,又不会让用户觉得眼花缭乱。这篇文章会详细讲解列表页面的实现,包括分类筛选的逻辑、网格布局的优化,还有一些实际开发中遇到的问题。列表页面用的是@override这里没什么特别的,标准的 StatefulWidget 写法

写在前面
盲盒列表页面这个页面其实挺重要的,因为用户想找特定类型的盲盒时,都会来这里。
我的想法很简单:让用户能快速找到想要的盲盒,同时浏览体验要流畅。所以用了网格布局,配合分类筛选,这样既能展示足够多的盲盒,又不会让用户觉得眼花缭乱。
这篇文章会详细讲解列表页面的实现,包括分类筛选的逻辑、网格布局的优化,还有一些实际开发中遇到的问题。
页面要做什么
在开始写代码之前,我先想了一下这个页面需要哪些功能。盲盒列表页面的核心就是展示和筛选。
主要功能:
- 顶部分类筛选栏,可以切换不同系列
- 网格布局展示盲盒卡片
- 每个卡片显示图片、名称、价格
- 点击卡片跳转到开盒页面
整个页面的设计思路是:简单直接,不要搞得太复杂。用户来这里就是想看看有什么盲盒,然后选一个开,所以交互要尽量简单。
页面骨架搭建
先定义页面类
列表页面用的是 StatefulWidget,因为需要管理分类切换的状态:
class BlindBoxListPage extends StatefulWidget {
const BlindBoxListPage({super.key});
State<BlindBoxListPage> createState() => _BlindBoxListPageState();
}
这里没什么特别的,标准的 StatefulWidget 写法。super.key 是 Flutter 2.5 之后推荐的写法,用于优化 widget 的复用性能。
状态变量定义
class _BlindBoxListPageState extends State<BlindBoxListPage> {
int _selectedCategoryIndex = 0;
final List<String> _categories = ['全部', '尝鲜袋', '潮玩袋', '乐玩喜袋', '3C福袋', '乐享袋'];
为什么要用
_selectedCategoryIndex?因为用户点击分类时,需要知道当前选中的是哪个。用索引比用字符串更高效,而且可以直接用来访问数据数组。
初始值设为 0,表示默认选中"全部"分类。
盲盒数据组织
final List<List<Map<String, dynamic>>> _blindBoxes = [
// 全部
[
{'name': '尝鲜福袋', 'price': 29, 'image': '...', 'category': '尝鲜袋'},
// ... 更多数据
],
// 尝鲜袋
[
{'name': '尝鲜福袋', 'price': 29, 'image': '...'},
// ...
],
// 其他分类...
];
为什么用二维列表?
外层列表的索引对应分类索引,内层列表存储该分类下的所有盲盒。这样当用户切换分类时,可以直接通过索引获取数据,不需要每次都筛选,性能更好。
虽然看起来数据有重复("全部"里包含了所有分类的盲盒),但这样设计能让代码更简单,而且实际项目中数据都是从后端获取的,不会有这个问题。
页面主体布局
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: CustomAppBar(title: '潮盒'),
body: Column(
children: [
_buildCategoryScroll(),
Expanded(child: _buildBlindBoxGrid()),
],
),
);
}
布局思路:
- 用
Column垂直排列,上面是分类栏,下面是网格列表Expanded让网格列表占据剩余的所有空间- 分类栏固定高度,不会因为内容变化而改变
这种布局很常见,简单实用。顶部固定,下面可滚动,用户操作起来很顺手。
分类筛选栏实现
分类栏容器
Widget _buildCategoryScroll() {
return Container(
height: 50,
decoration: BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: const Color(0xFFEEEEEE), width: 1)),
),
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: _categories.length,
itemBuilder: (context, index) {
// ...
},
),
);
}
设计细节:
- 高度固定为 50,这样不会因为内容变化而改变布局
- 底部加了一条浅灰色边框,用来分隔分类栏和列表内容
ListView.builder设置为横向滚动,这样分类多的时候可以滑动查看为什么不用
SingleChildScrollView?因为ListView.builder有懒加载的优势,而且滚动性能更好。
分类项的点击处理
final isSelected = index == _selectedCategoryIndex;
return GestureDetector(
onTap: () {
setState(() {
_selectedCategoryIndex = index;
});
},
child: Container(
// ...
),
);
点击逻辑:
点击分类项时,更新
_selectedCategoryIndex,然后调用setState触发界面重建。这样分类栏会更新选中状态,下面的网格列表也会切换到对应的数据。用
GestureDetector而不是InkWell,是因为这里不需要水波纹效果,GestureDetector更轻量。
分类项的样式
Container(
alignment: Alignment.center,
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_categories[index],
style: TextStyle(
fontSize: 14,
color: isSelected ? Colors.black : AppColors.grey,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
)
样式设计:
- 选中的分类显示主题色背景,未选中的透明
- 圆角设置为 20,让按钮看起来更圆润
- 文字颜色和字重也会根据选中状态变化,这样对比更明显
试过很多种样式,最后发现这种最简单也最有效。用户一眼就能看出哪个分类被选中了。
盲盒网格布局
获取当前分类的数据
Widget _buildBlindBoxGrid() {
final boxes = _blindBoxes[_selectedCategoryIndex];
return GridView.builder(
// ...
);
}
为什么在这里获取数据?
因为每次
build方法被调用时(比如切换分类),都会重新执行这个方法,所以能拿到最新的数据。如果放在initState里,切换分类时数据不会更新。
网格布局配置
GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 0.85,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: boxes.length,
itemBuilder: (context, index) {
return _buildBoxCard(context, boxes[index]);
},
)
参数说明:
crossAxisCount: 2表示两列布局,这样既能展示足够多的盲盒,又不会让卡片太小childAspectRatio: 0.85控制卡片的宽高比,0.85 表示高度略大于宽度,适合展示图片和文字crossAxisSpacing和mainAxisSpacing都是 12,既保证了视觉呼吸感,又不会浪费太多空间为什么用
GridView.builder而不是GridView?因为builder版本有懒加载,只渲染可见区域的卡片,性能更好。
盲盒卡片实现
卡片的点击跳转
Widget _buildBoxCard(BuildContext context, Map<String, dynamic> box) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const OpenBoxPage()),
);
},
child: Container(
// ...
),
);
}
跳转逻辑:
点击卡片时,使用
Navigator.push跳转到开盒页面。这里暂时没有传递盲盒数据,实际项目中应该把盲盒信息传过去,这样开盒页面才能显示对应的内容。
卡片容器样式
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFEEEEEE), width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
// ...
),
)
样式细节:
- 白色背景,让卡片在页面上更突出
- 圆角 12,看起来更柔和
- 浅灰色边框,增加层次感
- 轻微阴影(透明度 0.05),让卡片看起来有浮起来的感觉
阴影不能太重,否则会显得脏。我试过很多次,0.05 的透明度刚好。
图片展示
Expanded(
child: NetworkImageWithLoading(
imageUrl: box['image'],
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
backgroundColor: AppColors.lightGrey,
),
),
图片处理:
Expanded让图片占据卡片的上半部分空间NetworkImageWithLoading是封装好的组件,会在加载时显示占位符,加载失败时显示错误提示BoxFit.cover确保图片填满整个区域,同时保持比例不变形- 顶部圆角与卡片圆角保持一致,视觉上更统一
这个组件在首页也用了,确实很好用。不用自己处理加载状态,省了不少事。
文字信息区域
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
box['name'],
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.black,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// ...
],
),
),
文字处理:
- 名称用粗体显示,让用户一眼看到重点
maxLines: 1和overflow: TextOverflow.ellipsis确保名称过长时显示省略号,不会破坏布局CrossAxisAlignment.start让文字左对齐,符合阅读习惯
价格和按钮布局
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'¥${box['price']}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.red,
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: const Text('开箱', /* ... */),
),
],
),
布局设计:
- 价格用红色高亮显示,吸引用户注意
- 开箱按钮用主题色的半透明背景,既不会太抢眼,又能清晰地标识可点击区域
spaceBetween让价格和按钮分别靠左右两侧,布局更平衡按钮的圆角是 12,跟卡片圆角一致,看起来更协调。
一些细节优化
数据结构的考虑
虽然现在用的是二维列表,但实际项目中应该用更规范的方式:
class BlindBox {
final String name;
final int price;
final String image;
final String category;
BlindBox({required this.name, required this.price, required this.image, required this.category});
}
为什么要用类而不是 Map?
- 类型安全:用类可以在编译时发现错误,用 Map 只能在运行时发现
- 代码提示:IDE 能提供更好的代码补全
- 可维护性:如果字段改了,编译器会提示所有需要修改的地方
虽然现在用 Map 也能跑,但项目大了之后就会发现问题。建议从一开始就用类。
性能优化
GridView.builder(
// ...
itemBuilder: (context, index) {
return _buildBoxCard(context, boxes[index]);
},
)
为什么用 builder?
GridView.builder只会构建可见区域的卡片,当用户滚动时,才会构建新的卡片,同时回收不可见的卡片。这样即使有几百个盲盒,也不会卡顿。如果用普通的
GridView,会一次性构建所有卡片,数据多了就会卡。
图片加载优化
NetworkImageWithLoading(
imageUrl: box['image'],
backgroundColor: AppColors.lightGrey,
)
占位符的作用:
图片加载时显示灰色占位符,这样用户不会看到空白。加载失败时也显示占位符,而不是显示错误图标,这样体验更好。
实际项目中,还可以考虑使用缓存,这样已经加载过的图片下次就不用重新下载了。
踩过的坑
分类切换时列表不更新
一开始我遇到一个问题:切换分类时,网格列表没有更新。
原因:
我在
_buildBlindBoxGrid方法里获取数据,但忘记在setState里调用这个方法了。Flutter 的刷新机制是:只有setState里的代码变化,才会触发重建。解决方案:
确保
_buildBlindBoxGrid在build方法里被调用,这样每次setState时都会重新执行,数据就会更新了。
网格布局的间距问题
一开始网格的间距设置得太大,导致一屏只能看到很少的盲盒。
解决方案:
试了很多次,最后发现
crossAxisSpacing和mainAxisSpacing都设为 12 最合适。既能保证视觉呼吸感,又不会浪费空间。如果屏幕比较小,可以适当减小间距,但不要小于 8,否则会显得拥挤。
图片加载慢的问题
用户反馈说图片加载很慢,有时候要等很久。
解决方案:
- 使用
NetworkImageWithLoading组件,加载时显示占位符- 考虑使用图片缓存库(比如
cached_network_image)- 后端提供不同尺寸的图片,列表页用小图,详情页用大图
现在先用占位符,至少用户不会看到空白。后续可以优化图片加载策略。
长名称破坏布局
有些盲盒名称很长,导致卡片布局被破坏。
解决方案:
给名称文字加上
maxLines: 1和overflow: TextOverflow.ellipsis,这样名称过长时会显示省略号,不会破坏布局。如果名称真的很重要,可以考虑显示两行,但一般一行就够了。
写在最后
盲盒列表页面看起来简单,但要做好其实也不容易。关键是要把握好展示和筛选的平衡:既要让用户看到足够多的盲盒,又要让他们能快速找到想要的。
我的设计原则:
-
布局要清晰。分类栏和列表区域要明确分开,用户不会搞混。
-
交互要简单。点击分类就切换,点击卡片就跳转,不需要额外的操作。
-
性能要优化。用
GridView.builder实现懒加载,即使有很多盲盒也不会卡。
做完这个页面后,我拿给朋友试用,反馈都还不错。有人说"找盲盒很方便",有人说"卡片设计得挺好看的",这就够了。
一些经验:
-
网格布局的间距很重要。太小会显得拥挤,太大会浪费空间。我试了很多次,12 最合适。
-
图片加载一定要做优化。用户不会等很久,如果加载慢,体验就毁了。
-
分类筛选的逻辑要简单。不要搞得太复杂,用户就是想快速找到想要的盲盒。
如果你也在做类似的项目,希望这篇文章能给你一些启发。代码不是最重要的,重要的是理解用户的需求,然后设计出合适的交互。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)