打车票根卡片 UI 重构:从 Circle 挖洞到 clipShape PathShape,再到 100% 自适应

打车票根卡片 UI 重构:从 Circle 挖洞到 clipShape PathShape,再到 100% 自适应

项目:MyApplication(AI 助手 demo) 目标文件:chat/src/main/ets/components/PickupConfirmCardComp.ets 主题:把"确认上车点"卡片从早期的 Circle.offset + clip 挖洞 方案换成 clipShape PathShape 一次性绘出整张票根轮廓 ,再把所有写死的宽高拿掉,让卡片在不同消息容器宽度下都能渲染。这一篇是 21-arkui-ticket-card-clipshape-pathshape 的实战续篇 ------ 把"路由行的 ? 圆点、虚线、定位图标"、"进站口按钮 + list 菜单"、"自适应布局"三块组件级细节讲清楚。


一、卡片到底长什么样

text 复制代码
┌──────────────────────────────┐
│  确认上车点                  │  ← 标题 + 黄色高亮 + 蓝色渐变抬头
│  ──                          │
│  ? --- 您在哪里上车?        │  ← 路由行:? 圆点 + 虚线 + 📍 + 双行文字
│  |     香港中国企业协会      │
│  📍                          │
├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┤  ← 撕线 + 左右半圆缺口(票根的"撕开线")
│                              │
│  ① 香港西九龙站  65.9km      │
│     香港特别行政区...        │
│  [   进站口1   ]  [≡]        │  ← Text chip + list 图标按钮
│  ── 分隔线 ──                │
│  ② 高铁西九龙站(K出口)       │
│     香港特别行政区...        │
│  ── 分隔线 ──                │
│  ③ 高铁西九龙站(K出口)       │
│                              │
│  [    查看更多    ]          │  ← 浅蓝底蓝字按钮
└──────────────────────────────┘

上半部分是 抬头 (渐变蓝 + 黄高亮 + 路由行),下半部分是 白色 POI 区 (3 个上车点 + 分隔线 + 查看更多),中间靠 撕线 + 左右半圆缺口 形成票根感。


二、为什么从 Circle.offset 换到 clipShape PathShape

2.1 旧方案的"撕线"是这么做的

ts 复制代码
// 旧版(commit 之前)
Stack({ alignContent: Alignment.Center }) {
  Line().strokeDashArray([4, 4]).width('100%')      // 中间水平虚线

  Row() {
    Circle({ width: 14, height: 14 })
      .fill(this.theme.bg)
      .offset({ x: -7, y: 0 })                       // 左圆心移到卡片左边缘

    Blank()

    Circle({ width: 14, height: 14 })
      .fill(this.theme.bg)
      .offset({ x: 7, y: 0 })                        // 右圆心移到卡片右边缘
  }
}

外层 Column 给 .clip(true),落在外面的半圆被裁掉,形成左右两个半圆挖洞

这套方案能跑,但有 3 个问题

问题 表现
① 圆色绑定外层主题 .fill(theme.bg) 跟卡片所在背景同色。一旦卡片被嵌进别的颜色容器(比如 ChatListComp 里背景被换了),圆就成了"挖不掉"的色块
② shadow 顺着方形外框 整张卡片是方形,shadow 是方形 shadow。缺口处没有阴影,"撕开"的视觉不立体
③ 写死的 Stack + Row + Circle 圆心位置靠 .offset(-7, 0) 推到边缘。一旦缺口位置要跟着抬头高度变(不同字数会让抬头高度不同),调整成本高

2.2 新方案:clipShape 一刀切出"整张票根轮廓"

clipShape(PathShape) 把整张卡片的轮廓(4 个圆角 + 左右两个半圆缺口)作为一条 SVG path 命令喂给 ArkUI,整张卡的可见区域 = 这条 path 内部

ts 复制代码
.clipShape(new PathShape({ commands: this.getCardPath() }))

getCardPath() 返回的命令:

text 复制代码
M r 0                                      ← 从左上圆角终点开始
H w-r  A r r 0 0 1 w r                     ← 上边 + 右上圆角
V ny-nr A nr nr 0 0 0 w ny+nr              ← 右边到缺口上沿 + 右半圆向内挖
V h-r  A r r 0 0 1 w-r h                   ← 右边继续 + 右下圆角
H r    A r r 0 0 1 0 h-r                   ← 下边 + 左下圆角
V ny+nr A nr nr 0 0 0 0 ny-nr              ← 左边到缺口下沿 + 左半圆向内挖
V r    A r r 0 0 1 r 0 Z                   ← 左边收尾 + 左上圆角

带来 3 个改变:

维度
缺口的"色" 圆 fill 主题色硬贴上去 path 真的把那块镂空,背景透出来,永远跟得上外层颜色
shadow 方形外框 沿着 path 描边,缺口处真的有阴影"凹"进去
缺口位置 写死在中间 notchY 来自抬头 onAreaChange ------ 不管抬头多高,缺口永远卡在抬头和白色区的交界处

path 实现细节参见 21-arkui-ticket-card-clipshape-pathshape,这里不再展开。本篇之后聚焦"路由行 + 进站口按钮 + 自适应"三块组件级细节。


三、路由行:? 圆点 / 虚线 / 📍 的三段式

抬头里"您在哪里上车?/ 香港中国企业协会"这一行,左侧是个 12vp 宽的小柱 :上面一个黑色圆里的白色 ?,中间一段虚线,下面一个 📍 图标。看起来普通,里面有几个值得记一下的小技巧。

3.1 黑色圆里的白色 ? ------ 不用 Stack,直接 Text 当圆

第一反应一定是这么写:

ts 复制代码
Stack() {
  Circle({ width: 12, height: 12 }).fill('#E6000000')
  Text('?').fontSize(8).fontColor('#FFFFFF')
}

能跑,但多了一层 Stack 。其实更短的写法是让 Text 本身就是圆

ts 复制代码
Text('?')
  .fontSize(8)
  .fontColor('#FFFFFF')
  .textAlign(TextAlign.Center)      // 横向居中
  .width(12)
  .height(12)
  .lineHeight(12)                   // 等于 height → 纵向几何居中
  .backgroundColor('#E6000000')     // Text 自带 background → 黑底
  .borderRadius(256)                // 超大半径 → 强制变圆
  .margin({ top: 2 })

关键三件套:

属性 作用
textAlign(Center) 文字横向居中
lineHeight(12)height(12) 一致 文字纵向几何居中(baseline 不在中点,但行高强行居中显示)
borderRadius(256) 任何 >= width/2 的值都能强制圆,写 256 是社区习惯

为什么 fontSize 选 8 而不是 10 :12vp 的圆里,问号字形高度需要 < ~10vp 才不会顶到边。fontSize(8) + lineHeight(12) 让问号上下各有 ~2vp 留白,视觉上更像"圆点里的字符"而不是"塞满圆的字符"。

这种"Text 当圆"写法的本质:Text 是 CommonMethod,自带 background / borderRadius / padding。但凡需要"一个圆 + 中间一个字符",都可以省掉 Stack。

3.2 虚线段

ts 复制代码
Line()
  .width(1)
  .height(12)
  .startPoint([0, 0])
  .endPoint([0, 12])
  .stroke('#B5BAC4')
  .strokeWidth(1)
  .strokeDashArray([2, 2])         // 实线 2vp + 空 2vp 交替

strokeDashArray([2, 2]) 是核心,控制 dash 的"实 / 空"长度。改成 [4, 2] 就是长实线短空,按视觉调整。

3.3 📍 图标

ts 复制代码
Image($r('app.media.location'))
  .width(12)
  .height(12)
  .objectFit(ImageFit.Contain)
  .draggable(false)                // 防止长按触发系统拖拽
  .margin({ top: 2 })              // 跟虚线之间留 2vp gap

draggable(false) 是踩过坑的:Image 默认 draggable 为 true,长按会触发系统拖拽预览(半透明阴影 + 飘起来),用户以为卡死了。所有非用户主动拖的 Image 都加这一条

3.4 三段式组合

ts 复制代码
Column() {
  Text('?') ...               // 圆点 12×12
  Line() ...                  // 虚线 1×12
  Image(location) ...         // 📍 12×12
}
.width(12)
.alignItems(HorizontalAlign.Center)

整列宽度写死 12 + 横向居中,每个子元素都 12 宽,视觉上就是一根 12vp 的细柱


四、进站口按钮 + list 菜单:layoutWeight(1) + Image 包 Column 居中

第一个 POI(香港西九龙站)下面有一行:

text 复制代码
[      进站口1      ]   [ ≡ ]

左边是个带框的 chip,右边是个带框的 list 菜单按钮(公司项目里通常点击展开切换进站口)。

4.1 第一版写法 ------ 三横线模拟菜单

最早右边那个 list 按钮我直接用 3 个细长 Row 画了 ≡:

ts 复制代码
Column({ space: 3 }) {
  Row().width(14).height(2).backgroundColor('#99000000').borderRadius(1)
  Row().width(14).height(2).backgroundColor('#99000000').borderRadius(1)
  Row().width(14).height(2).backgroundColor('#99000000').borderRadius(1)
}
.padding(7)
.border({ width: 1, color: '#1A000000', radius: 8 })

能跑、不依赖外部资源,但真要还原设计稿 ------ 设计稿里那个 list 图标是带细节的(每根横线左边一个小圆点)------ 三个 Row 是糊不出来的。

4.2 第二版:Image + Column 容器

设计组给了 list.png,丢到 chat/src/main/resources/base/media/list.png,写法变成:

ts 复制代码
Column() {
  Image($r('app.media.list'))
    .width(14)
    .height(14)
    .objectFit(ImageFit.Contain)
    .draggable(false)
}
.padding(7)
.border({ width: 1, color: '#1A000000', radius: 8 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)

为什么外层套个 Column 而不是直接给 Image 加 padding + border

  • Image 直接 .padding(7) 时,padding 会进入图片的可绘制区域 ,让 objectFit(Contain) 重新计算 → 图片实际渲染尺寸不再是 14×14
  • 套一个 Column,把"内边距 + 边框"剥离给容器,Image 始终是 14×14,可控

这个细节是踩过一次坑才意识到的 ------ 在公共组件里给 Image 加 padding 是个错误模式,容器管布局,图片管绘制

4.3 左边 chip:layoutWeight(1) 让进站口自动撑开

ts 复制代码
Row() {
  Text('进站口1')
    .layoutWeight(1)                   // ← 关键:吃掉除 list 按钮外的所有宽度
    .maxLines(1)
    .textOverflow({ overflow: TextOverflow.Ellipsis })
    .textAlign(TextAlign.Center)       // chip 内文字居中(被层级压缩时也好看)
    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
    .border({ width: 1, color: '#1A000000', radius: 8 })

  Column() { Image($r('app.media.list')) ... }       // list 按钮,自然宽度 30vp 左右
    .margin({ left: 4 })
}
.width('100%')
.alignItems(VerticalAlign.Bottom)

layoutWeight(1) 是 Flex 子项的"弹性占比"。在 Row 里 = "占满剩余宽度"。绝对不要写死 .width(264),因为:

  • 父容器宽度变了(不同消息气泡宽度不同)→ 264 就溢出或留白
  • 进站口文字变了("进站口1" → "进站口10号航站楼")→ 264 不够装

layoutWeight(1) 才是"自适应布局"的正确写法。


五、自适应 vs 写死:踩过的坑

中间有一版我为了"完美还原 UI 稿",把 Stack + 票根 Column 的宽高全写死:

ts 复制代码
.width(328).height(414)        // Stack
.width(328).height(400)        // 票根 Column
.width(264)                    // 进站口 Text

结果下面 POI 直接被裁了一节。原因:

text 复制代码
Stack(414)
  └─ Column(286)   ← 我写的高度
      ├─ Header(114)              ← 抬头实际高度
      ├─ TearLine(0)              ← 撕线
      └─ WhiteArea(剩 286-114=172)  ← 内容自然撑开需要 305,被 clipShape 裁掉 ~133

设计稿上"白色区 286"是白色区 的高度,不是整张票根 Column 的高度。整张 Column = 抬头 114 + 撕线 0 + 白色 286 = 400

这次的结论:先 100% 渲染,再调对齐

最终方案是把所有写死宽高拿掉:

ts 复制代码
// Stack
.width('100%')
.constraintSize({ maxWidth: '92%' })   // 只锁最大不锁绝对值

// 票根 Column
.width('100%')                          // 没有 .height

// 进站口 Text
.layoutWeight(1)                        // 没有 .width
  • 高度让内容自然撑开 → 抬头 + 撕线 + 白色区,加起来多大就多大
  • 宽度跟父容器走,封顶 92%(防止贴满气泡边)
  • 缺口 notchY 通过 onAreaChange 实时拿抬头高度算出来,跟谁的 layout 都解耦

心智模型 :UI 复刻时的优先级是 能渲染 > 像 > 一模一样。先保证三层结构都渲染出来,再回头量尺寸调像素。先写死,clipShape 一裁,问题全藏在裁掉的部分里反而看不见。


六、阴影顺序敏感 ------ clipShape 放在 shadow 之前还是之后?

这个坑我之前在 21 也提过,再强调一次:

ts 复制代码
Column() { ... }
  .clipShape(...)        // ① 先裁形状
  .shadow({ ... })       // ② 沿裁完的形状描阴影

顺序反了

ts 复制代码
.shadow({ ... })       // ① 先沿方形外框出阴影
.clipShape(...)        // ② 再裁形状 → 阴影也被裁掉了!

不仅缺口处没阴影,整张卡的阴影全部消失。clipShape 永远在 shadow 之前


七、@Builder 拆分时的响应式坑

整个组件用了 5 个 @Builder 拆模块化:

ts 复制代码
@Builder TicketHeader()       // 抬头
@Builder RouteRow()           // 抬头里的路由行
@Builder IndexBadge(num)      // POI 序号圆点
@Builder Divider()            // 分隔线
@Builder LocationItemFull()   // 第 1 个完整 POI
@Builder LocationItemSimple() // 第 2/3 个简化 POI
@Builder MoreButton()         // 查看更多

响应式字段必须在 @ComponentV2.build 顶层访问,不能透过 @Builder 参数传

ts 复制代码
// ❌ 错:响应式字段透过参数传给 @Builder,参数里的依赖丢失,UI 不刷新
@Builder
HeaderWithTitle(title: string) {       // title 来自 @Local
  Text(title)
}
build() {
  this.HeaderWithTitle(this.title)
}

// ✅ 对:@Builder 内部直接读 this.xxx
@Builder
TicketHeader() {
  Text(this.title)                     // 直接 this 读,依赖被 @ComponentV2 收集
}

我这次封装时所有响应式字段(headerH / notchY)都是 @Builderthis.xxx 直读,没有当参数传,所以 layout 变化能正常触发 getCardPath() 重算。


八、一句话心智模型

text 复制代码
旧票根 = Circle.offset + clip → 撕线靠贴色块、shadow 跟着方形
新票根 = clipShape(PathShape) → 整张轮廓一刀切出来,缺口真的镂空,shadow 沿 path

? 圆点 = Text + textAlign + lineHeight + borderRadius(256),省一层 Stack
list 按钮 = Image 包 Column 居中,padding 给容器不给图

自适应 > 写死,先 100% 渲染再量像素
clipShape 永远在 shadow 之前

九、顺口溜

text 复制代码
PathShape 一刀切,圆角缺口一次成;
Text 当圆别 Stack,居中三件 lineHeight 平。

Image 别加 padding,外面套层 Column 守;
进站口走 layoutWeight,写死高度坑下游。

shadow 别走在裁前,撕线缺口才有影;
@Builder 参数不响应,this 直读才会刷。

十、参考

相关推荐
傅科摆 _ py1 小时前
AI Ping 平台使用教程
java·前端·人工智能
lichenyang4531 小时前
聊天历史从 Preferences 搬到关系型数据库(RDB):为什么换、怎么换、踩了什么坑
前端
HjhIron1 小时前
从栈到队列,再到链表:前端开发者必知的线性数据结构
前端·javascript
PedroQue991 小时前
uni-app路由管理神器:vue-router风格体验
前端·uni-app
用户1733598075371 小时前
花两周用 Vue 3 做了个 PDF 工具站,我在生产环境踩了 8 个坑
前端·vue.js
风骏时光牛马1 小时前
TypeScript 泛型与工具类型实战:企业级通用数据请求封装完整案例
前端
阿猫的故乡1 小时前
Vue自定义指令从入门到实用:自动聚焦、权限控制、防抖、懒加载……全案例教学
前端·javascript·vue.js
嘟嘟07171 小时前
吃透 JS 八大数据类型与内存原理,从代码到底层一站式复习
前端
问心无愧05131 小时前
ctf show web入门157 158
前端·笔记