Flutter for OpenHarmony 盲盒抽奖App应用实战 - 优惠券列表实现
今天我们来实现盲盒App中的优惠券列表功能。这个功能看似简单,但其实包含了很多细节:Tab切换、优惠券卡片设计、状态管理、空状态处理等。在实际开发中,我发现优惠券列表是电商类App的标配功能,用户体验的好坏直接影响到转化率。一个设计精美、交互流畅的优惠券列表,能够有效提升用户的购买欲望。Tab切换:可使用、已使用、已过期三个状态优惠券卡片:精美的卡片设计,包含金额、条件、有效期等信息状态管理:不同

写在前面
今天我们来实现盲盒App中的优惠券列表功能。这个功能看似简单,但其实包含了很多细节:Tab切换、优惠券卡片设计、状态管理、空状态处理等。
在实际开发中,我发现优惠券列表是电商类App的标配功能,用户体验的好坏直接影响到转化率。一个设计精美、交互流畅的优惠券列表,能够有效提升用户的购买欲望。
功能概述
我们要实现的优惠券列表包含以下功能:
- Tab切换:可使用、已使用、已过期三个状态
- 优惠券卡片:精美的卡片设计,包含金额、条件、有效期等信息
- 状态管理:不同状态下的优惠券展示不同的样式
- 空状态处理:当没有优惠券时显示友好的提示
- 立即使用:点击按钮可以直接使用优惠券
页面结构设计
首先,让我们来看看整体的页面结构:
import 'package:flutter/material.dart';
import '../../config/app_colors.dart';
class CouponListPage extends StatefulWidget {
const CouponListPage({super.key});
State<CouponListPage> createState() => _CouponListPageState();
}
这里我们创建了一个StatefulWidget,因为页面需要管理Tab切换的状态。使用StatefulWidget可以让我们在用户切换Tab时动态更新界面。
State类的定义
接下来,我们定义State类并初始化TabController:
class _CouponListPageState extends State<CouponListPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
这里有几个关键点:
- SingleTickerProviderStateMixin:这是Flutter提供的一个混入类,用于提供Ticker,TabController需要它来实现动画效果
- late关键字:表示这个变量会在使用前被初始化,但不是在声明时立即初始化
- TabController:用于管理Tab的切换状态
模拟数据准备
在实际项目中,优惠券数据应该从后端API获取。但为了演示,我们先使用模拟数据:
final List<Map<String, dynamic>> _availableCoupons = [
{
'id': '1',
'title': '新人专享券',
'amount': 10,
'condition': '满50可用',
'validDate': '2026-02-14',
'type': 'discount',
},
{
'id': '2',
'title': '盲盒优惠券',
'amount': 5,
'condition': '满30可用',
'validDate': '2026-01-31',
'type': 'discount',
},
每个优惠券对象包含:
- id:唯一标识符
- title:优惠券名称
- amount:优惠金额
- condition:使用条件
- validDate:有效期
- type:类型(discount折扣券、shipping免邮券)
{
'id': '3',
'title': '免邮券',
'amount': 0,
'condition': '全场通用',
'validDate': '2026-03-01',
'type': 'shipping',
},
];
免邮券的amount为0,因为它不是金额优惠,而是免除运费。
final List<Map<String, dynamic>> _usedCoupons = [
{
'id': '4',
'title': '开箱立减券',
'amount': 8,
'condition': '满40可用',
'validDate': '2026-01-10',
'type': 'discount',
},
];
final List<Map<String, dynamic>> _expiredCoupons = [
{
'id': '5',
'title': '限时折扣券',
'amount': 15,
'condition': '满100可用',
'validDate': '2025-12-31',
'type': 'discount',
},
];
我们准备了三个列表,分别对应三种状态的优惠券。这样可以方便地在不同Tab中展示不同的数据。
生命周期管理
初始化
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
在initState方法中,我们创建了TabController:
- length: 3:表示有3个Tab(可使用、已使用、已过期)
- vsync: this:将当前State作为TickerProvider,这样TabController就能获取到动画所需的Ticker
资源释放
void dispose() {
_tabController.dispose();
super.dispose();
}
非常重要:在dispose方法中释放TabController,避免内存泄漏。这是很多初学者容易忽略的地方,如果不释放,会导致内存占用越来越高。
页面布局构建
主体结构
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
title: const Text('我的优惠券'),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
elevation: 0,
这里我们设置了:
- backgroundColor:浅灰色背景,让卡片更有层次感
- AppBar:使用主题色,标题为白色
- elevation: 0:去掉AppBar的阴影,让界面更简洁
TabBar设计
bottom: TabBar(
controller: _tabController,
indicatorColor: Colors.white,
indicatorWeight: 3,
labelColor: Colors.white,
unselectedLabelColor: Colors.white70,
tabs: [
Tab(text: '可使用 (${_availableCoupons.length})'),
Tab(text: '已使用 (${_usedCoupons.length})'),
Tab(text: '已过期 (${_expiredCoupons.length})'),
],
),
),
TabBar的设计要点:
- indicatorColor:指示器颜色设为白色,与主题色形成对比
- indicatorWeight:指示器粗细为3,更加醒目
- labelColor:选中的Tab文字为白色
- unselectedLabelColor:未选中的Tab文字为半透明白色
- 动态显示数量:在Tab文字中显示对应状态的优惠券数量,让用户一目了然
TabBarView内容
body: TabBarView(
controller: _tabController,
children: [
_buildCouponList(_availableCoupons, CouponStatus.available),
_buildCouponList(_usedCoupons, CouponStatus.used),
_buildCouponList(_expiredCoupons, CouponStatus.expired),
],
),
);
}
TabBarView包含三个子页面,每个页面都调用_buildCouponList方法,传入不同的数据和状态。这种设计可以复用代码,避免重复。
优惠券列表构建
列表主体
Widget _buildCouponList(
List<Map<String, dynamic>> coupons, CouponStatus status) {
if (coupons.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.local_offer_outlined,
size: 64, color: Colors.grey.shade300),
const SizedBox(height: 16),
Text(
'暂无优惠券',
style: TextStyle(fontSize: 16, color: Colors.grey.shade500),
),
],
),
);
}
空状态处理非常重要:
- 当列表为空时,显示一个友好的提示界面
- 使用大号图标和文字,让用户清楚地知道当前没有优惠券
- 居中显示,视觉效果更好
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: coupons.length,
itemBuilder: (context, index) {
return _buildCouponCard(coupons[index], status);
},
);
}
使用ListView.builder而不是ListView:
- 性能更好,只会构建可见的item
- 适合数据量较大的场景
- padding设置为16,让卡片与屏幕边缘保持距离
优惠券卡片设计
卡片外层容器
Widget _buildCouponCard(Map<String, dynamic> coupon, CouponStatus status) {
final bool isAvailable = status == CouponStatus.available;
final Color primaryColor =
isAvailable ? AppColors.primary : Colors.grey.shade400;
final Color bgColor = isAvailable ? Colors.white : Colors.grey.shade100;
首先判断优惠券的状态:
- isAvailable:是否可用
- primaryColor:可用时使用主题色,不可用时使用灰色
- bgColor:可用时背景为白色,不可用时为浅灰色
这种设计让用户一眼就能区分优惠券的状态。
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
boxShadow: isAvailable
? [
BoxShadow(
color: AppColors.primary.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
]
: null,
),
卡片容器的设计:
- margin:底部间距12,让卡片之间有适当的间隔
- borderRadius:圆角12,更加柔和
- boxShadow:可用的优惠券添加阴影效果,增加层次感;不可用的优惠券不添加阴影
卡片布局结构
child: Row(
children: [
// 左侧金额区域
Container(
width: 100,
padding: const EdgeInsets.symmetric(vertical: 20),
卡片采用Row布局,分为左右两部分:
- 左侧显示金额或免邮标识
- 右侧显示优惠券详细信息
左侧区域固定宽度100,这样可以保证所有优惠券的金额区域对齐,视觉效果更整齐。
左侧金额区域
decoration: BoxDecoration(
gradient: isAvailable
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, AppColors.primaryDark],
)
: null,
color: isAvailable ? null : Colors.grey.shade300,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
bottomLeft: Radius.circular(12),
),
),
金额区域的背景设计:
- 可用状态:使用渐变色,从主题色到深色,更有质感
- 不可用状态:使用纯灰色
- borderRadius:只设置左侧的圆角,与卡片外层圆角保持一致
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (coupon['type'] == 'shipping')
const Text(
'免邮',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
)
免邮券的特殊处理:
- 使用
if语句判断类型 - 直接显示"免邮"文字,字体大小24
- 白色粗体,醒目清晰
else
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'¥',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
Text(
'${coupon['amount']}',
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
折扣券的金额显示:
- 人民币符号¥字体较小(14),放在左上角
- 金额数字字体很大(32),突出显示
- 使用
crossAxisAlignment.start让符号和数字顶部对齐
const SizedBox(height: 4),
Text(
coupon['condition'],
style: TextStyle(
fontSize: 10,
color: Colors.white.withOpacity(0.9),
),
),
],
),
),
在金额下方显示使用条件,字体较小,颜色稍微透明,不抢主要信息的风头。
右侧信息区域
// 右侧信息区域
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
coupon['title'],
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isAvailable ? AppColors.black : Colors.grey,
),
),
右侧使用Expanded占据剩余空间:
- 标题字体16,粗体
- 可用时黑色,不可用时灰色
- 左对齐,符合阅读习惯
const SizedBox(height: 8),
Text(
'有效期至 ${coupon['validDate']}',
style: TextStyle(
fontSize: 12,
color: isAvailable ? AppColors.grey : Colors.grey.shade400,
),
),
有效期信息:
- 字体较小(12),作为辅助信息
- 颜色较浅,不干扰主要信息
- 间距8,与标题保持适当距离
操作按钮
const SizedBox(height: 12),
if (isAvailable)
GestureDetector(
onTap: () {
Navigator.pop(context);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
decoration: BoxDecoration(
border: Border.all(color: primaryColor),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'立即使用',
style: TextStyle(
fontSize: 12,
color: primaryColor,
fontWeight: FontWeight.w500,
),
),
),
)
可用优惠券的按钮:
- 使用
if条件渲染,只在可用状态下显示 - GestureDetector:处理点击事件
- Navigator.pop:点击后返回上一页,通常是在订单确认页选择优惠券
- 边框按钮:只有边框没有填充,更轻量
- 圆角20:胶囊形状,更现代
else
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 6),
child: Text(
status == CouponStatus.used ? '已使用' : '已过期',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade400,
),
),
),
],
),
),
),
],
),
);
}
}
不可用优惠券的状态文字:
- 根据status显示"已使用"或"已过期"
- 灰色文字,表示不可操作
- 没有边框和背景,纯文字展示
状态枚举定义
enum CouponStatus { available, used, expired }
定义一个枚举类型来表示优惠券的三种状态:
- available:可使用
- used:已使用
- expired:已过期
使用枚举而不是字符串的好处:
- 类型安全:编译时就能发现错误
- 代码提示:IDE会自动提示可用的值
- 易于维护:修改状态名称时,编译器会提示所有需要修改的地方
交互逻辑详解
Tab切换动画
TabController自动处理Tab切换的动画效果,我们不需要手动编写动画代码。当用户点击Tab或滑动页面时:
- TabController会自动更新当前索引
- TabBar的指示器会平滑移动到新位置
- TabBarView会滑动到对应的页面
- 选中的Tab文字颜色会变化
这一切都是自动完成的,这就是Flutter框架的强大之处。
优惠券选择流程
在实际应用中,优惠券列表通常是从订单确认页跳转过来的:
- 用户在订单确认页点击"选择优惠券"
- 跳转到优惠券列表页
- 用户选择一张可用的优惠券
- 点击"立即使用"按钮
- 返回订单确认页,并将选中的优惠券信息传回去
我们的代码中使用Navigator.pop(context)返回上一页。如果需要传递数据,可以这样写:
Navigator.pop(context, coupon);
然后在订单确认页接收:
final selectedCoupon = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => CouponListPage()),
);
状态更新机制
当优惠券被使用后,应该从"可使用"列表移到"已使用"列表。这需要:
- 调用后端API标记优惠券为已使用
- 更新本地数据
- 调用
setState刷新界面
示例代码:
void _useCoupon(String couponId) {
setState(() {
// 从可使用列表中移除
final coupon = _availableCoupons.firstWhere((c) => c['id'] == couponId);
_availableCoupons.remove(coupon);
// 添加到已使用列表
_usedCoupons.add(coupon);
});
}
性能优化建议
1. 使用const构造函数
在代码中,我们大量使用了const关键字:
const Text('我的优惠券')
const SizedBox(height: 16)
const EdgeInsets.all(16)
这样做的好处是:
- 这些对象在编译时就创建好了
- 多次使用时不会重复创建
- 减少内存占用和GC压力
2. ListView.builder的优势
我们使用ListView.builder而不是ListView:
ListView.builder(
itemCount: coupons.length,
itemBuilder: (context, index) {
return _buildCouponCard(coupons[index], status);
},
)
性能优势:
- 只构建可见的item
- 滚动时动态创建和销毁item
- 适合长列表场景
3. 避免不必要的重建
我们将卡片构建逻辑抽取到单独的方法_buildCouponCard中:
Widget _buildCouponCard(Map<String, dynamic> coupon, CouponStatus status) {
// ...
}
这样做的好处:
- 代码结构清晰
- 便于维护和测试
- 可以单独优化这个方法的性能
UI设计细节
1. 颜色搭配
我们的优惠券卡片使用了精心设计的颜色方案:
可用状态:
- 背景:纯白色(#FFFFFF)
- 金额区域:渐变色(主题色到深色)
- 文字:黑色和主题色
- 阴影:主题色半透明
不可用状态:
- 背景:浅灰色(#F5F5F5)
- 金额区域:灰色(#BDBDBD)
- 文字:深灰色
- 无阴影
这种设计让用户一眼就能区分优惠券的状态,提升了用户体验。
2. 间距设计
合理的间距让界面更加舒适:
- 卡片间距:12px,不会太挤也不会太松
- 内边距:16px,让内容与边缘保持距离
- 文字间距:4-12px,根据重要性调整
- 按钮内边距:横向16px,纵向6px
3. 字体层级
我们使用了清晰的字体层级:
- 金额数字:32px,最大最醒目
- 标题:16px,粗体,次要重点
- 有效期:12px,辅助信息
- 按钮文字:12px,操作提示
- 条件说明:10px,最小的辅助信息
4. 圆角设计
统一的圆角让界面更加和谐:
- 卡片外层:12px圆角
- 金额区域:左侧12px圆角
- 按钮:20px圆角(胶囊形状)
扩展功能实现
1. 优惠券筛选
可以添加筛选功能,让用户快速找到想要的优惠券:
List<Map<String, dynamic>> _filterCoupons(String keyword) {
return _availableCoupons.where((coupon) {
return coupon['title'].toString().contains(keyword);
}).toList();
}
2. 优惠券排序
可以按金额、有效期等维度排序:
void _sortByAmount() {
setState(() {
_availableCoupons.sort((a, b) {
return (b['amount'] as int).compareTo(a['amount'] as int);
});
});
}
3. 下拉刷新
添加下拉刷新功能,让用户可以手动刷新优惠券列表:
RefreshIndicator(
onRefresh: _refreshCoupons,
child: ListView.builder(
// ...
),
)
Future<void> _refreshCoupons() async {
// 调用API获取最新优惠券
await Future.delayed(Duration(seconds: 1));
setState(() {
// 更新数据
});
}
4. 优惠券详情
点击卡片可以查看优惠券的详细信息:
GestureDetector(
onTap: () {
_showCouponDetail(coupon);
},
child: _buildCouponCard(coupon, status),
)
void _showCouponDetail(Map<String, dynamic> coupon) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(coupon['title']),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('优惠金额:¥${coupon['amount']}'),
Text('使用条件:${coupon['condition']}'),
Text('有效期至:${coupon['validDate']}'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('关闭'),
),
],
),
);
}
数据管理方案
1. 本地存储
可以使用shared_preferences保存优惠券数据:
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
Future<void> _saveCoupons() async {
final prefs = await SharedPreferences.getInstance();
final couponsJson = jsonEncode(_availableCoupons);
await prefs.setString('coupons', couponsJson);
}
Future<void> _loadCoupons() async {
final prefs = await SharedPreferences.getInstance();
final couponsJson = prefs.getString('coupons');
if (couponsJson != null) {
setState(() {
_availableCoupons = List<Map<String, dynamic>>.from(
jsonDecode(couponsJson)
);
});
}
}
2. 状态管理
对于复杂的应用,建议使用Provider或Riverpod进行状态管理:
class CouponProvider extends ChangeNotifier {
List<Map<String, dynamic>> _coupons = [];
List<Map<String, dynamic>> get availableCoupons {
return _coupons.where((c) => c['status'] == 'available').toList();
}
void useCoupon(String id) {
final index = _coupons.indexWhere((c) => c['id'] == id);
if (index != -1) {
_coupons[index]['status'] = 'used';
notifyListeners();
}
}
}
3. API集成
实际项目中需要对接后端API:
import 'package:http/http.dart' as http;
Future<List<Map<String, dynamic>>> _fetchCoupons() async {
final response = await http.get(
Uri.parse('https://api.example.com/coupons'),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return List<Map<String, dynamic>>.from(data['coupons']);
} else {
throw Exception('Failed to load coupons');
}
}
错误处理
1. 网络错误
try {
final coupons = await _fetchCoupons();
setState(() {
_availableCoupons = coupons;
});
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载失败,请检查网络连接')),
);
}
2. 数据异常
Widget _buildCouponCard(Map<String, dynamic> coupon, CouponStatus status) {
// 数据校验
if (coupon['title'] == null || coupon['amount'] == null) {
return SizedBox.shrink(); // 返回空widget
}
// 正常渲染
return Container(
// ...
);
}
3. 用户操作错误
void _useCoupon(Map<String, dynamic> coupon) {
// 检查是否已过期
final validDate = DateTime.parse(coupon['validDate']);
if (validDate.isBefore(DateTime.now())) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('该优惠券已过期')),
);
return;
}
// 正常使用
Navigator.pop(context, coupon);
}
踩过的坑
在开发优惠券列表功能时,我遇到了一些问题,这里分享给大家:
1. TabController未释放导致内存泄漏
问题描述:
刚开始我忘记在dispose方法中释放TabController,导致页面多次进入后内存占用越来越高。
**解决
更多推荐


所有评论(0)