撕边券面卡片:ArkUI 让鸿蒙卡券页像钱包里的优惠券


实例:卡券管理(Coupon)|风格:撕边券面
一、设计理念:卡券要「像一张真的券」
卡券管理的 UI 目标是手机钱包/卡包——每张券是一张「撕边券面」:渐变底色 + 大面值 + 左右半圆撕边孔。实例 18 的风格「撕边券面」:渐变券面卡(左右撕边孔)+ 状态筛选条 + 使用/长按删除。
页面信息架构:
- 标题栏:卡券管理 + 标语 + 刷新;
- 状态筛选条:全部 N / 未使用 N / 已使用 N / 已过期 N(带数量);
- 卡券列表:撕边券面卡(渐变底 + 面值 + 信息 + 状态/操作);
- 交互:点券看详情、未使用点「使用」、长按删除。
二、页面骨架与核心状态
@State coupons: Coupon[] = [];
@State stats: Record<string, number> = {};
@State statusFilter: number = -1;
private readonly statusNames: string[] = ['未使用', '已使用', '已过期'];
private readonly statusColors: string[] = ['#059669', '#9CA3AF', '#D1D5DB'];
核心状态:coupons(卡券列表)、stats(各状态数量)、statusFilter(-1 全部 / 0~2 状态)。statusNames/statusColors:状态 → 名称/颜色的映射数组。
build 骨架:
Column() {
// 标题栏
// 状态筛选条
// 卡券列表(撕边券面)
}
三、状态筛选条:带数量的 tab
Scroll() {
Row({ space: 8 }) {
Text(`全部 ${this.totalCount()}`)
.fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.statusFilter === -1 ? '#111827' : '#FFFFFF')
.fontColor(this.statusFilter === -1 ? Color.White : '#4B5563')
.onClick(() => this.switchFilter(-1))
ForEach(this.statusNames, (name: string, idx: number) => {
Text(`${name} ${this.stats[String(idx)] ?? 0}`)
.fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.statusFilter === idx ? this.statusColors[idx] : '#FFFFFF')
.fontColor(this.statusFilter === idx ? Color.White : '#4B5563')
.onClick(() => this.switchFilter(idx))
}, (name: string, idx: number) => `${idx}-${name}`)
}.padding({ left: 16, right: 16, top: 10 })
}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')
tab 带数量:未使用 5 / 已使用 2 / 已过期 3——统计数字直接显示在 tab 里(stats 来自 GROUP BY status)。选中色按状态:未使用选中绿色、已使用灰色、已过期更灰——状态色贯穿筛选条。
totalCount 计算:
private totalCount(): number {
let sum = 0;
Object.keys(this.stats).forEach((k: string) => {
sum += this.stats[k];
});
return sum;
}
Object.keys 遍历统计对象:把各状态数量累加——「全部 N」的总数。Record<string, number> 的遍历:Object.keys + forEach(ArkTS 兼容写法)。
四、撕边券面卡片:核心视觉
每张券用 Stack 组合:渐变底 + 左右半圆撕边孔 + 内容区:
Stack({ alignContent: Alignment.Center }) {
// 渐变券面底
Row().width('100%').height(96).borderRadius(12)
.linearGradient({ angle: 135, colors: [[c.color, 0], [c.color + 'CC', 1]] })
// 左右半圆撕边孔(底色圆,露出页面背景)
Row()
.width(24).height(24).borderRadius(12).backgroundColor('#F8FAFC')
.position({ x: -12, y: 36 })
Row()
.width(24).height(24).borderRadius(12).backgroundColor('#F8FAFC')
.position({ x: 'calc(100% - 12px)', y: 36 })
// 内容区
Row() {
// 面值区
Column() {
Text(`¥${c.faceValue}`)
.fontSize(30).fontWeight(FontWeight.Bold).fontColor(Color.White)
Text(c.minSpend > 0 ? `满 ${c.minSpend} 可用` : '无门槛')
.fontSize(10).fontColor('rgba(255,255,255,0.85)').margin({ top: 2 })
}.width(90)
// 分割虚线
Row().width(1).height(56).backgroundColor('rgba(255,255,255,0.5)')
// 信息区
Column({ space: 3 }) {
Text(c.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(Color.White).maxLines(1)
Text(`${c.type} · 有效期至 ${this.fmtDate(c.expireTime)}`)
.fontSize(10).fontColor('rgba(255,255,255,0.85)')
if (c.status === 0) {
Text(`剩 ${this.daysLeft(c)} 天`).fontSize(10).padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8).backgroundColor('rgba(255,255,255,0.25)').fontColor(Color.White)
}
}.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 })
// 状态 / 操作
if (c.status === 0) {
Text('使用').fontSize(13).padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14).backgroundColor(Color.White).fontColor(c.color)
.onClick(() => this.useCoupon(c))
} else {
Text(this.statusNames[c.status])
.fontSize(12).padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12).backgroundColor('rgba(255,255,255,0.3)').fontColor(Color.White)
}
}
.width('100%').padding({ left: 14, right: 14 })
}
.width('100%').height(96)
.onClick(() => this.showDetail(c))
.gesture(
LongPressGesture().onAction(() => this.deleteCoupon(c))
)
撕边券面的四个视觉元素:
1. 渐变券面底:linearGradient({ angle: 135, colors: [[c.color, 0], [c.color + 'CC', 1]] })——135 度对角渐变,c.color + 'CC' 是加透明度后缀(原色 → 80% 透明同色的渐变)——同一色相的深浅渐变(比实例 17 的两色渐变更细腻)。
2. 左右半圆撕边孔:两个 24×24 圆(#F8FAFC 页面背景色)position({ x: -12, y: 36 }) 半嵌在券面左右边缘——用页面底色圆「挖」出撕边孔(视觉骗术:圆在券面上方,露出背景色形成缺口)。左侧 x: -12(左移一半露出半圆)、右侧 x: 'calc(100% - 12px)'(calc 计算定位——右侧边缘同理)。**「底色圆模拟撕边」**是零图片资源实现撕边券的经典技巧。
3. 内容三区:面值区(¥20 大字 + 满 100 可用)+ 竖分割线 + 信息区(标题 + 类型·有效期至 + 剩 N 天)。
4. 状态/操作:未使用 → 白色「使用」按钮(字色为券色);已使用/已过期 → 半透明白状态标签。
剩 N 天:
private daysLeft(c: Coupon): number {
return Math.ceil((c.expireTime - Date.now()) / 86400000);
}
Math.ceil 向上取整:过期时间距今的天数(剩 3 天 2 小时 → ceil = 4 天?——实际 3 天多一点 → ceil 到 4 天偏保守;或用户视角「3 天内到期」)。**「剩余天数 = (expire - now) / 天毫秒」**的时间差计算。
手势:
.gesture(
LongPressGesture().onAction(() => this.deleteCoupon(c))
)
LongPressGesture 长按手势(首次登场)——长按券面触发删除(与点按查看详情区分)。「点按 + 长按」双手势:onClick 查看详情、长按删除——移动端常用交互分层。
五、状态与操作分支
状态驱动 UI:c.status === 0(未使用)显示「使用」按钮 + 「剩 N 天」;else 显示状态标签(已使用灰/已过期更灰)——「状态决定可操作性与视觉」。
使用流程:
useCoupon(c: Coupon): void {
promptAction.showDialog({
title: '使用卡券',
message: `确认使用「${c.title}」吗?`,
buttons: [
{ text: '取消', color: '#808080' },
{ text: '使用', color: '#059669' },
],
}).then((res: promptAction.ShowDialogSuccessResponse) => {
if (res.index === 1) {
CouponDao.useCoupon(this.context, c.id).then(async () => {
await this.refresh();
promptAction.showToast({ message: '✅ 已使用' });
});
}
});
}
确认框 → useCoupon → refresh:使用是「消耗性操作」需确认——「使用」按钮绿色(正向操作)。
详情弹窗(点券面):
showDetail(c: Coupon): void {
promptAction.showDialog({
title: c.title,
message: `类型:${c.type}\n面值:¥${c.faceValue}\n门槛:${c.minSpend > 0 ? `满 ${c.minSpend} 可用` : '无门槛'}\n生效:${this.fmtDate(c.startTime)}\n过期:${this.fmtDate(c.expireTime)}\n状态:${this.statusNames[c.status]}\n备注:${c.remark}`,
buttons: [{ text: '知道了', color: '#3B82F6' }],
});
}
多行信息弹窗:\n 拼接 7 行卡券详情(类型/面值/门槛/生效/过期/状态/备注)——信息完整展示。
六、UI 风格要素一览
| 风格项 | 取值 | 说明 |
|---|---|---|
| 页面背景 | #F8FAFC 极浅灰蓝 |
衬托券面 |
| 券面 | 135° 渐变(色 + 透明后缀) | 券面质感 |
| 撕边孔 | 底色圆半嵌左右边缘 | 零资源撕边 |
| 面值 | 30fp 白色大字 | 视觉焦点 |
| 状态色 | 绿/灰/灰 | 状态可辨 |
| 手势 | 点按详情 + 长按删除 | 交互分层 |
七、文章小结
本篇文章完成了实例 18 的 UI 层:带数量状态筛选条(统计数字入 tab)+ 撕边券面卡(渐变底 + 底色圆撕边孔 + 面值/信息/操作三区)+ 点按详情/长按删除双手势。设计核心是「钱包券面感」——linearGradient 渐变 + 半透明后缀(c.color + 'CC')+ 底色圆撕边孔(零图片资源);技术亮点是 LongPressGesture 长按手势(首次登场)与 calc 定位。卡券页像钱包里的优惠券夹。
下一篇(18-3)深入数据层,讲解有效期比较、批量过期更新与状态筛选——markExpired 是本实例的技术核心。
八、撕边券面实现细节深入
1. 渐变 + 透明后缀
.linearGradient({ angle: 135, colors: [[c.color, 0], [c.color + 'CC', 1]] })
c.color + 'CC' 的透明度后缀:Hex 颜色 8 位格式(RRGGBBAA)——CC 十六进制 = 204/255 ≈ 80% 不透明。原色 #EF4444 → #EF4444CC:同一色相从实到虚的过渡,比实例 17 的「橙→粉两色渐变」更细腻——单色系深浅渐变更接近真实券面的印刷质感。注意后缀拼在颜色值末尾:c.color 是 6 位 #RRGGBB,直接拼接 'CC' 得到 8 位 #RRGGBBAA,ArkUI 的 linearGradient 支持 8 位色值。
2. 底色圆撕边孔
Row().width(24).height(24).borderRadius(12).backgroundColor('#F8FAFC')
.position({ x: -12, y: 36 })
「用页面底色圆挖孔」:圆半径 12px、背景 #F8FAFC(与页面背景同色),x: -12 把圆心对准券面左边缘——左半圆在券面内、右半圆落在页面背景上,视觉上券面左缘「缺」了一个半圆。关键前提:圆颜色必须与页面背景完全一致(#F8FAFC),否则一眼穿帮。
3. calc 定位
.position({ x: 'calc(100% - 12px)', y: 36 })
calc 计算定位:100% 是券面宽度,calc(100% - 12px) 把圆右移 12px 使圆心对准右边缘——与左侧 x: -12 完全对称。ArkUI 的 position 支持 calc 表达式(百分比与长度单位混合运算),是「相对父容器右缘定位」的标准写法。
九、状态驱动的使用/标签切换
| 状态 | 操作区 | 信息区 | 视觉 |
|---|---|---|---|
| 0 未使用 | 白色「使用」按钮 | 剩 N 天标签 | 可操作(白底彩字) |
| 1 已使用 | 半透明状态标签 | 无 | 静态(灰) |
| 2 已过期 | 半透明状态标签 | 无 | 静态(更灰) |
一个 if 分支决定操作与视觉:c.status === 0 是「可操作态」——渲染可点击的「使用」按钮 + 「剩 N 天」倒计时;else 是「静态态」——只渲染状态名标签。状态即 UI 的开关:筛选条选中色、券面按钮、状态标签全部由 status 一个值驱动。
十、长按手势 LongPressGesture
.gesture(
LongPressGesture().onAction(() => this.deleteCoupon(c))
)
**LongPressGesture(长按约 500ms 触发)**与 onClick 点按并存——点击查看详情、长按删除,同一控件两种手势按触发时序区分。删除是破坏性操作,放在长按(比点按更难误触)里符合移动端交互惯例;onAction 在长按完成时回调。
十一、剩余天数计算
private daysLeft(c: Coupon): number {
return Math.ceil((c.expireTime - Date.now()) / 86400000);
}
86400000 = 24h × 60min × 60s × 1000ms(一天的毫秒数)。(expireTime - Date.now()) 是剩余毫秒,除以一天毫秒得剩余天数(可能带小数),Math.ceil 向上取整——剩 0.5 天显示「剩 1 天」,保证用户不会在当天看到「剩 0 天」而误以为已过期。
十二、FAQ
| 问题 | 回答 |
|---|---|
| 渐变为什么用透明后缀而不是两色? | 单色系同色相渐变更接近真实券面质感,且只需券主色一个值 |
| 撕边圆为什么用页面背景色? | 零图片资源「挖孔」——圆与背景同色即视觉缺口 |
| calc 定位比固定像素好在哪? | 券面宽度变化时自动跟随右缘,无需手动算像素 |
| 长按和点按会冲突吗? | 不会——点按立即触发、长按约 500ms 后触发,时序不同 |
| 剩 N 天为什么用 ceil? | 避免「今天到期显示剩 0 天」的歧义,向上取整偏保守 |
更多推荐




所有评论(0)