在HarmonyOS 6购物比价或电商类应用中,优惠券卡包是高频场景:用户需要一眼看到"当前可用"券,也能切换到"已过期"券查看历史。最常见的坑是------数据切换时代码直接改数组导致Tab页闪烁、过期券未做视觉区分(置灰/打删除线)、Tab切换后列表未回到顶部。
本文将基于官方行业实践示例,用 Tabs + List + @State过滤驱动 完整实现一个带双Tab分页、过期置灰、自动滚顶的优惠券卡包页面。
一、需求拆解与设计
1. 页面结构
Tabs(顶部切换)
├── TabContent "可使用" → List<Coupon> (status === 'valid')
└── TabContent "已过期" → List<Coupon> (status === 'expired')
2. 卡片视觉规则
| 状态 | 背景 | 文字 | 附加标记 |
|---|---|---|---|
| 可用 | 渐变主色 | 深色 | --- |
| 已过期 | 灰色 #EAEAEA |
#999置灰 |
右上角"已过期"标签 / 删除线 |
二、数据模型与模拟数据源
// model/Coupon.ets
export interface Coupon {
id: number;
title: string; // 优惠描述,如"满199减50"
discount: string; // 金额 "¥50"
expireDate: string; // "2026-08-31"
status: 'valid' | 'expired'; // 业务状态
}
// mock/couponData.ets
import { Coupon } from '../model/Coupon';
export const ALL_COUPONS: Coupon[] = [
{ id: 1, title: '满199减50', discount: '¥50', expireDate: '2026-08-31', status: 'valid' },
{ id: 2, title: '满99减20', discount: '¥20', expireDate: '2026-07-15', status: 'valid' },
{ id: 3, title: '新人专享减30', discount: '¥30', expireDate: '2025-12-31', status: 'expired' },
{ id: 4, title: '满299减80', discount: '¥80', expireDate: '2025-11-01', status: 'expired' },
];
三、核心页面实现(Tabs + List + 过滤驱动)
// pages/CouponPage.ets
import { ALL_COUPONS } from '../mock/couponData';
import { Coupon } from '../model/Coupon';
@Entry
@Component
struct CouponPage {
@State activeTab: number = 0; // 0=可用 1=过期
// 按Tab过滤数据源(派生状态,不单独维护两份数组)
get displayList(): Coupon[] {
return this.activeTab === 0
? ALL_COUPONS.filter(c => c.status === 'valid')
: ALL_COUPONS.filter(c => c.status === 'expired');
}
// List引用,用于切换Tab时滚回顶部
private listScroller: Scroller = new Scroller();
build() {
Column() {
// ===== 顶部标题 =====
Text('我的优惠券')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.padding({ top: 16, bottom: 12, left: 16 })
// ===== Tab切换 =====
Tabs({
barPosition: BarPosition.Start,
index: this.activeTab
}) {
TabContent() {
this.buildCouponList()
}.tabBar('可使用')
TabContent() {
this.buildCouponList()
}.tabBar('已过期')
}
.barMode(BarMode.Fixed)
.barWidth('100%')
.barHeight(44)
.onChange((idx: number) => {
this.activeTab = idx;
// 切换Tab滚回顶部
this.listScroller.scrollToIndex(0, true, ScrollAlign.START);
})
}
.width('100%')
.height('100%')
.backgroundColor('#F5F6F8')
}
// ===== 优惠券列表 =====
@Builder
buildCouponList() {
List({ scroller: this.listScroller }) {
ForEach(this.displayList, (item: Coupon) => {
ListItem() {
this.buildCard(item)
}
.padding({ horizontal: 16, vertical: 6 })
}, (item: Coupon) => item.id.toString())
}
.width('100%')
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring)
}
// ===== 单张优惠券卡片 =====
@Builder
buildCard(item: Coupon) {
const isExpired = item.status === 'expired';
Row() {
// 左侧金额区
Column() {
Text(item.discount)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(isExpired ? '#999' : '#FF5722')
Text('满减券')
.fontSize(10)
.fontColor(isExpired ? '#BBB' : '#888')
}
.width(80)
.alignItems(HorizontalAlign.Center)
// 右侧信息区
Column() {
Text(item.title)
.fontSize(15)
.fontColor(isExpired ? '#999' : '#333')
.decoration({
type: isExpired ? TextDecorationType.LINE_THROUGH : TextDecorationType.NONE,
color: '#BBB'
})
Text(`有效期至 ${item.expireDate}`)
.fontSize(11)
.fontColor('#AAA')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
// 过期标签
if (isExpired) {
Text('已过期')
.fontSize(10)
.fontColor('#FFF')
.backgroundColor('#BBB')
.borderRadius(4)
.padding({ horizontal: 6, vertical: 2 })
}
}
.padding(14)
.backgroundColor(isExpired ? '#EAEAEA' : '#FFFFFF')
.borderRadius(12)
.shadow({
radius: 4,
color: 'rgba(0,0,0,0.06)',
offsetX: 0,
offsetY: 2
})
}
}
四、避坑指南
| 问题 | 原因 | 修复 |
|---|---|---|
| Tab切换列表不回顶 | List默认保持滚动偏移 | scroller.scrollToIndex(0)在 onChange中调用 |
| 过期券与可用券混在一起 | 用单一数组未过滤 | 通过 activeTab派生 displayList过滤 |
| 卡片状态不变 | ForEach第二个参数key不唯一或用了数组索引 |
key用 item.id(唯一且稳定) |
| 过期视觉不明显 | 仅改文字颜色 | 同时改背景色+置灰+删除线+过期标签 |
五、总结:优惠券卡包实现要点
-
Tabs驱动过滤 :用
@State activeTab派生displayList,不改原始数据源 -
Scroller滚顶 :
onChange中scrollToIndex(0)保体验 -
过期视觉三板斧 :灰色背景 +
#999文字 + 删除线 + "已过期"标签 -
ForEach key:始终用业务唯一ID,避免Tab切换时列表重绘异常
通过这套 Tabs + List + 状态过滤的模式,你的优惠券卡包将具备清晰的双态分页与符合用户预期的过期置灰表现。
©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任。