请添加图片描述

写在前面

今天我们来实现盲盒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:已过期

使用枚举而不是字符串的好处:

  1. 类型安全:编译时就能发现错误
  2. 代码提示:IDE会自动提示可用的值
  3. 易于维护:修改状态名称时,编译器会提示所有需要修改的地方

交互逻辑详解

Tab切换动画

TabController自动处理Tab切换的动画效果,我们不需要手动编写动画代码。当用户点击Tab或滑动页面时:

  1. TabController会自动更新当前索引
  2. TabBar的指示器会平滑移动到新位置
  3. TabBarView会滑动到对应的页面
  4. 选中的Tab文字颜色会变化

这一切都是自动完成的,这就是Flutter框架的强大之处。

优惠券选择流程

在实际应用中,优惠券列表通常是从订单确认页跳转过来的:

  1. 用户在订单确认页点击"选择优惠券"
  2. 跳转到优惠券列表页
  3. 用户选择一张可用的优惠券
  4. 点击"立即使用"按钮
  5. 返回订单确认页,并将选中的优惠券信息传回去

我们的代码中使用Navigator.pop(context)返回上一页。如果需要传递数据,可以这样写:

Navigator.pop(context, coupon);

然后在订单确认页接收:

final selectedCoupon = await Navigator.push(
  context,
  MaterialPageRoute(builder: (_) => CouponListPage()),
);

状态更新机制

当优惠券被使用后,应该从"可使用"列表移到"已使用"列表。这需要:

  1. 调用后端API标记优惠券为已使用
  2. 更新本地数据
  3. 调用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,导致页面多次进入后内存占用越来越高。

**解决

Logo

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

更多推荐