在这里插入图片描述
在这里插入图片描述

实例:卡券管理(Coupon)|风格:撕边券面

一、设计理念:卡券要「像一张真的券」

卡券管理的 UI 目标是手机钱包/卡包——每张券是一张「撕边券面」:渐变底色 + 大面值 + 左右半圆撕边孔。实例 18 的风格「撕边券面」:渐变券面卡(左右撕边孔)+ 状态筛选条 + 使用/长按删除

页面信息架构:

  1. 标题栏:卡券管理 + 标语 + 刷新;
  2. 状态筛选条:全部 N / 未使用 N / 已使用 N / 已过期 N(带数量);
  3. 卡券列表:撕边券面卡(渐变底 + 面值 + 信息 + 状态/操作);
  4. 交互:点券看详情、未使用点「使用」、长按删除。

二、页面骨架与核心状态

@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 查看详情、长按删除——移动端常用交互分层。

五、状态与操作分支

状态驱动 UIc.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 天」的歧义,向上取整偏保守
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐