Flutter for OpenHarmony 盲盒抽奖App应用实战 - 商品兑换实现
今天我们来实现盲盒App中的商品兑换功能。这是一个非常实用的功能,用户可以将仓库中不想要的商品兑换成潮豆,然后用潮豆去购买其他商品或盲盒。在电商和游戏类App中,虚拟货币兑换是常见的功能。设计这个功能时,我深刻体会到交互流程设计的重要性。一个好的兑换流程应该:清晰展示兑换信息、支持数量选择、有明确的确认步骤、给用户清晰的反馈。这篇文章我会详细讲解商品兑换页面的实现,包括商品展示、数量选择器、价格计

写在前面
今天我们来实现盲盒App中的商品兑换功能。这是一个非常实用的功能,用户可以将仓库中不想要的商品兑换成潮豆,然后用潮豆去购买其他商品或盲盒。
在电商和游戏类App中,虚拟货币兑换是常见的功能。设计这个功能时,我深刻体会到交互流程设计的重要性。一个好的兑换流程应该:清晰展示兑换信息、支持数量选择、有明确的确认步骤、给用户清晰的反馈。
这篇文章我会详细讲解商品兑换页面的实现,包括商品展示、数量选择器、价格计算、确认对话框等。每段代码都会有详细的解释和设计思路。
功能概述
商品兑换页面包含以下功能:
- 商品信息展示:显示商品名称、图片、库存数量
- 选择功能:通过复选框选择要兑换的商品
- 数量选择器:支持增减数量,不能超过库存
- 价格显示:实时显示单价和总价
- 底部操作栏:显示总价和确认按钮
- 确认对话框:二次确认避免误操作
- 页面跳转:兑换成功后跳转到成功页面
页面结构设计
首先看整体的页面结构:
import 'package:flutter/material.dart';
import '../../config/app_colors.dart';
import 'exchange_success_page.dart';
class ExchangeProductPage extends StatefulWidget {
final Map<String, dynamic> product;
const ExchangeProductPage({super.key, required this.product});
State<ExchangeProductPage> createState() => _ExchangeProductPageState();
}
关键设计点:
- StatefulWidget:需要管理选择状态和数量
- product参数:从仓库页面传入要兑换的商品信息
- required修饰:product是必需参数,不能为空
设计思考:为什么不在这个页面获取商品列表?因为用户是从仓库页面点击"置换"按钮进来的,已经明确了要兑换哪个商品,直接传入更高效。
State类的实现
class _ExchangeProductPageState extends State<ExchangeProductPage> {
bool _isSelected = false;
int _quantity = 1;
final int _chaodouPrice = 28600;
int get _totalChaodou => _isSelected ? _chaodouPrice * _quantity : 0;
状态变量说明:
- _isSelected:商品是否被选中,控制复选框状态
- _quantity:要兑换的数量,默认为1
- _chaodouPrice:单个商品的潮豆价格,这里固定为28600
- _totalChaodou:计算属性,根据选中状态和数量计算总价
为什么用getter?
- 总价是计算出来的,不需要存储
- 使用getter可以自动响应_isSelected和_quantity的变化
- 代码更简洁,避免手动更新总价
构建主界面
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.black,
size: 20
),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'我的仓库',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
centerTitle: true,
),
AppBar的设计:
- 白色背景:与页面背景一致,简洁统一
- elevation: 0:去掉阴影,更扁平化
- iOS风格返回按钮:使用arrow_back_ios图标
- 标题居中:centerTitle: true
- 黑色文字:在白色背景上对比度好
平台适配:iOS用户习惯左上角的返回按钮,Android用户习惯左上角的返回箭头。这里使用iOS风格的图标,在两个平台上都能被接受。
body: Column(
children: [
// 商品列表
Expanded(
child: ListView(
children: [
_buildProductItem(),
],
),
),
// 底部操作栏
_buildBottomBar(),
],
),
);
}
布局结构:
- Column:垂直布局,商品列表在上,操作栏在下
- Expanded + ListView:商品列表占据剩余空间,可滚动
- 固定底部栏:操作栏固定在底部,不随列表滚动
为什么用ListView而不是直接放Widget?
- 虽然现在只有一个商品,但ListView提供了滚动能力
- 如果商品信息很长,可以滚动查看
- 为将来扩展多商品兑换预留空间
商品项的实现
Widget _buildProductItem() {
return Container(
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFFF5F5F5), width: 1),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 选择框
GestureDetector(
onTap: () => setState(() => _isSelected = !_isSelected),
child: Container(
width: 22,
height: 22,
margin: const EdgeInsets.only(top: 30, right: 8),
商品项的基础结构:
- Container包裹:提供padding和底部边框
- Row布局:横向排列选择框、图片、信息
- crossAxisAlignment.start:顶部对齐
- GestureDetector:让选择框可点击
选择框的位置:
- margin.top: 30:向下偏移30像素,与商品图片中部对齐
- margin.right: 8:与图片保持8像素间距
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isSelected ? AppColors.primary : Colors.white,
border: Border.all(
color: _isSelected
? AppColors.primary
: Colors.grey.shade400,
width: 1.5,
),
),
child: _isSelected
? const Icon(Icons.check, size: 14, color: Colors.white)
: null,
),
),
选择框的样式:
- 圆形:shape: BoxShape.circle
- 动态颜色:选中时黄色背景,未选中时白色背景
- 动态边框:选中时黄色边框,未选中时灰色边框
- 对勾图标:选中时显示白色对勾
交互反馈:选择框的状态变化要明显,让用户清楚知道是否已选中。颜色、边框、图标三重反馈,确保用户不会误操作。
// 商品图片
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Icon(
Icons.shopping_bag_outlined,
size: 50,
color: Colors.grey.shade300,
),
),
),
const SizedBox(width: 12),
商品图片区域:
- 固定尺寸:100x100像素,正方形
- 浅灰背景:F8F8F8颜色
- 圆角:8像素圆角
- 占位图标:使用购物袋图标作为占位符
为什么不显示真实图片?
- 这里为了演示简化了,实际项目中应该显示商品图片
- 可以使用
widget.product['image']获取图片URL - 建议使用NetworkImage或CachedNetworkImage加载
// 商品信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.product['name'] ?? '江疏影同款潮鞋匡威',
style: const TextStyle(
fontSize: 14,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
Text(
'数量:${widget.product['quantity'] ?? 1}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
const Spacer(),
// 数量选择器
_buildQuantitySelector(),
],
),
商品信息区域:
- Expanded:占据剩余宽度
- 商品名称:14号字体,单行显示,超出省略
- 库存数量:13号字体,灰色,次要信息
- 数量选择器:放在右侧,与库存数量同行
数据获取:
- 使用
widget.product['name']获取商品名称 - 使用
??运算符提供默认值,避免空值错误 - 这是Dart的空安全特性,非常实用
const SizedBox(height: 12),
Row(
children: [
Text(
'出售价格:',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
Text(
'$_chaodouPrice',
style: const TextStyle(
fontSize: 18,
color: Color(0xFFB8860B),
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 4),
const Text(
'潮豆',
style: TextStyle(
fontSize: 13,
color: Color(0xFFB8860B),
),
),
],
),
],
),
),
],
),
);
}
价格显示:
- 标签文字:“出售价格:”,13号灰色字体
- 价格数字:18号金色字体,加粗,突出显示
- 单位文字:“潮豆”,13号金色字体
- 金色主题:使用B8860B颜色,代表金币/货币
视觉层次:价格是最重要的信息,所以用最大的字号和最醒目的颜色。标签和单位用较小的字号,形成层次感。
数量选择器实现
这是一个非常实用的组件,很多电商App都有类似的设计。
Widget _buildQuantitySelector() {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 减少按钮
GestureDetector(
onTap: () {
if (_quantity > 1) {
setState(() => _quantity--);
}
},
选择器的基础结构:
- Container包裹:提供边框和圆角
- Row布局:横向排列减号、数字、加号
- mainAxisSize.min:宽度自适应内容
- GestureDetector:让按钮可点击
减少按钮的逻辑:
- 判断
_quantity > 1:数量不能小于1 - 使用
setState更新状态 - 使用
--运算符减1
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
border: Border(
right: BorderSide(color: Colors.grey.shade300),
),
),
child: Center(
child: Text(
'−',
style: TextStyle(
fontSize: 16,
color: _quantity > 1
? Colors.black54
: Colors.grey.shade300,
),
),
),
),
),
减号按钮的样式:
- 固定尺寸:28x28像素,正方形
- 右边框:与数字区域分隔
- 动态颜色:可用时黑色,不可用时灰色
- 减号符号:使用Unicode字符’−’
用户体验细节:
- 当数量为1时,减号按钮变灰,提示用户不能再减
- 但按钮仍然可以点击,只是不会有效果
- 这比禁用按钮更友好,用户可以尝试点击
// 数量显示
Container(
width: 36,
height: 28,
color: Colors.white,
child: Center(
child: Text(
'$_quantity',
style: const TextStyle(
fontSize: 14,
color: Colors.black87,
),
),
),
),
数字显示区域:
- 宽度36:比按钮稍宽,容纳两位数
- 白色背景:与按钮区分
- 居中显示:数字在中间
- 14号字体:清晰可读
// 增加按钮
GestureDetector(
onTap: () {
final maxQuantity = widget.product['quantity'] ?? 1;
if (_quantity < maxQuantity) {
setState(() => _quantity++);
}
},
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
border: Border(
left: BorderSide(color: Colors.grey.shade300),
),
),
child: const Center(
child: Text(
'+',
style: TextStyle(
fontSize: 16,
color: Colors.black54,
),
),
),
),
),
],
),
);
}
加号按钮的逻辑:
- 获取最大数量:从商品数据中获取库存
- 边界检查:不能超过库存数量
- 左边框:与数字区域分隔
- 加号符号:使用’+'字符
业务逻辑:兑换数量不能超过库存,这是基本的业务规则。在增加数量时必须检查,避免用户选择超出库存的数量。
底部操作栏实现
Widget _buildBottomBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
底部栏的样式:
- 白色背景:与页面背景一致
- 上方阴影:offset为(0, -2),阴影在上方
- 轻微阴影:opacity为0.05,不会太重
- 内边距:水平16,垂直12
为什么阴影在上方?
- 底部栏固定在底部,需要与上方内容区分
- 上方阴影可以营造"浮起"的效果
- 这是Material Design的常见做法
child: SafeArea(
child: Row(
children: [
// 总价
Expanded(
child: Row(
children: [
const Text(
'总价:',
style: TextStyle(
fontSize: 14,
color: Colors.black87,
),
),
Text(
'$_totalChaodou',
style: const TextStyle(
fontSize: 18,
color: Color(0xFFB8860B),
fontWeight: FontWeight.bold,
),
),
const Text(
'潮豆',
style: TextStyle(
fontSize: 14,
color: Color(0xFFB8860B),
),
),
],
),
),
// 确认置换按钮
GestureDetector(
onTap: _isSelected ? _showConfirmDialog : null,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12
),
decoration: BoxDecoration(
color: _isSelected
? AppColors.primary
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'确认置换',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
);
}
底部栏的内容:
- SafeArea包裹:避免被底部导航栏遮挡
- Row布局:总价在左,按钮在右
- Expanded:让总价区域占据剩余空间
- 动态按钮:选中时黄色可点击,未选中时灰色不可点击
按钮状态管理:
onTap: _isSelected ? _showConfirmDialog : null- 未选中时onTap为null,按钮不响应点击
- 选中时调用_showConfirmDialog显示确认对话框
- 颜色也随状态变化,给用户明确的视觉反馈
确认对话框实现
void _showConfirmDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16)
),
title: const Text('确认置换'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('确定要置换「${widget.product['name']}」吗?'),
const SizedBox(height: 12),
Row(
children: [
const Text('置换数量:'),
Text(
'$_quantity',
style: const TextStyle(fontWeight: FontWeight.bold)
),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text('获得潮豆:'),
Text(
'$_totalChaodou',
style: const TextStyle(
color: Color(0xFFB8860B),
fontWeight: FontWeight.bold,
),
),
],
),
],
),
确认对话框的设计:
- 圆角对话框:16像素圆角,现代风格
- 清晰的标题:“确认置换”
- 详细的信息:商品名称、置换数量、获得潮豆
- Column布局:信息垂直排列
- mainAxisSize.min:高度自适应内容
信息展示的重点:
- 商品名称用引号包裹,更醒目
- 数量和潮豆用加粗字体突出
- 潮豆数字用金色,与主题一致
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'取消',
style: TextStyle(color: Colors.grey)
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
// 跳转到成功页面
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const ExchangeSuccessPage()
),
);
},
child: const Text('确定'),
),
],
),
);
}
}
对话框按钮:
- 取消按钮:灰色文字,关闭对话框
- 确定按钮:主题色文字,执行置换操作
- pushReplacement:替换当前页面,不能返回
为什么用pushReplacement?
- 置换成功后,不应该让用户返回到置换页面
- 使用pushReplacement替换当前页面
- 用户只能从成功页面返回到仓库页面
用户体验:置换是不可逆操作,成功后不应该让用户返回重复操作。这是很多交易类App的通用做法。
踩过的坑
1. 数量选择器的边界问题
问题:最初没有限制最大数量,用户可以选择超过库存的数量。
解决:
if (_quantity < maxQuantity) {
setState(() => _quantity++);
}
教训:任何涉及数量的操作都要做边界检查,包括最小值和最大值。
2. 未选中时按钮仍可点击
问题:未选中商品时,确认按钮仍然可以点击,导致逻辑错误。
解决:
onTap: _isSelected ? _showConfirmDialog : null,
教训:按钮的可用状态要与业务逻辑一致,不可用时应该禁用。
3. 总价计算错误
问题:最初总价没有考虑选中状态,未选中时也显示价格。
解决:
int get _totalChaodou => _isSelected ? _chaodouPrice * _quantity : 0;
教训:计算属性要考虑所有相关状态,不能只考虑数量。
4. 页面跳转后数据未更新
问题:置换成功后返回仓库页面,数据没有更新。
解决:在仓库页面接收返回值并更新数据:
final result = await Navigator.push(...);
if (result != null && result['exchanged'] == true) {
setState(() {
// 更新数据
});
}
教训:页面间的数据传递要完整,包括去和回两个方向。
功能扩展建议
1. 批量兑换
支持一次选择多个商品兑换:
List<Map<String, dynamic>> _selectedProducts = [];
void _toggleProduct(Map<String, dynamic> product) {
setState(() {
if (_selectedProducts.contains(product)) {
_selectedProducts.remove(product);
} else {
_selectedProducts.add(product);
}
});
}
2. 兑换记录
显示历史兑换记录:
class ExchangeHistory extends StatelessWidget {
Widget build(BuildContext context) {
return ListView.builder(
itemCount: records.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(records[index]['name']),
subtitle: Text('兑换时间:${records[index]['time']}'),
trailing: Text('+${records[index]['chaodou']} 潮豆'),
);
},
);
}
}
3. 价格波动提示
如果兑换价格会变化,应该提示用户:
if (currentPrice != originalPrice) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('价格变动提示'),
content: Text('当前价格已变更为 $currentPrice 潮豆'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('知道了'),
),
],
),
);
}
4. 兑换限制
添加每日兑换限制:
class ExchangeLimit {
static const int dailyLimit = 10;
static int todayCount = 0;
static bool canExchange() {
return todayCount < dailyLimit;
}
static void recordExchange() {
todayCount++;
}
}
写在最后
商品兑换功能是盲盒App中重要的变现环节,通过这个功能的实现,我们学习了:
- 数量选择器:实现了一个通用的加减数量组件
- 状态管理:使用bool和int管理选择和数量状态
- 计算属性:使用getter实现自动计算的总价
- 边界检查:确保数量在合理范围内
- 确认流程:通过对话框实现二次确认
- 页面跳转:使用pushReplacement避免返回
- 动态样式:根据状态改变按钮颜色和可用性
开发心得:
在实现兑换功能时,我最大的感受是细节决定成败。比如:
- 数量选择器的边界检查
- 未选中时按钮的禁用
- 确认对话框的信息展示
- 页面跳转的方式选择
这些细节虽然不复杂,但如果处理不当,会严重影响用户体验。
技术亮点:
数量选择器是这个页面的技术亮点。它不仅要支持加减操作,还要:
- 限制最小值和最大值
- 根据状态改变样式
- 实时更新总价
- 提供清晰的视觉反馈
这是一个可以复用的组件,可以提取出来在其他地方使用。
业务逻辑:
兑换功能涉及的业务逻辑:
- 检查商品是否可兑换
- 验证兑换数量是否合法
- 计算兑换获得的潮豆
- 更新用户的商品和潮豆余额
- 记录兑换历史
在实际项目中,这些逻辑应该在后端实现,前端只负责展示和交互。
与OpenHarmony的适配:
这个页面的代码完全兼容OpenHarmony平台。所有的UI组件和交互逻辑都是Flutter标准实现,可以无缝运行在OpenHarmony上。
后续优化方向:
- 支持批量兑换多个商品
- 添加兑换记录查询功能
- 实现价格波动提示
- 添加每日兑换限制
- 优化加载和错误处理
希望这篇文章能帮助你理解商品兑换功能的实现。下一篇文章我们将实现兑换成功页面,展示兑换结果并引导用户进行下一步操作!
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐



所有评论(0)