ArkUI 票根卡片:PathShape 真挖洞,shadow 沿凹陷外发光

ArkUI 票根卡片:PathShape 真挖洞,shadow 沿凹陷外发光

项目:MyApplication(AI 助手 demo) 组件:ConfirmTripCardComp(确认行程卡片) 主题:实现"票根 / 车票"效果的卡片 ------ 四角圆角 + 左右两个半圆缺口 + 中间撕线 + 阴影。这篇记录我从"色块覆盖出来的假缺口"一路改到"PathShape clipShape 真裁剪"的全过程。


一、目标长什么样

AI 助手回复"打车"意图时,会下发一张"确认行程"卡片:

  • 渐变蓝色抬头(标题 + 起终点 + 距离 / 时长)
  • 中段一条横向虚线撕线
  • 撕线左右各有一个半圆缺口(票根口)
  • 下方白色区:地图、价格区间、选择车型、确认用车按钮
  • 整张卡有阴影,缺口处的阴影要"凹"进去,让票根有物理感

效果示意:

text 复制代码
┌──────────────────────────────────────────┐
│  确认行程                                 │
│  ─── (黄色高亮)                          │
│                                          │
│  ⊙ 香港中国企业协会          立即出发    │
│  ⋮  全程 34.2 公里      45 分钟          │
│  ⊙ 香港国际机场       预计 13:30 到达    │
└─( - - - - - - - - - - - - - - - - - - )─┘  ← 撕线 + 两侧缺口
│                                          │
│   ┌────────────────────────────────┐    │
│   │            [地图]              │    │
│   └────────────────────────────────┘    │
│   预估 145~155 人民币起   [选择车型]    │
│   已选中 1 种车型:                       │
│                                          │
│   ┌──────  确认用车  ──────┐             │
└──────────────────────────────────────────┘

关键视觉特征只有一个 ------ 左右两个真挖出去的半圆缺口。下面所有讨论都围绕这一点展开。


二、第一版:用 Circle 覆盖底色"挖洞"

最直觉的做法:撕线那一行做成 Stack,中间一条虚线、左右各放一个 填外层底色Circle,看起来就像"缺口"。

ts 复制代码
Stack({ alignContent: Alignment.Center }) {
  // 中间水平虚线
  Line()
    .height(1)
    .startPoint([0, 0])
    .endPoint([1000, 0])
    .stroke('#1A000000')
    .strokeWidth(1)
    .strokeDashArray([4, 4])
    .width('100%')
    .margin({ left: 12, right: 12 })

  // 左右两个跟底色一致的"假缺口"
  Row() {
    Circle({ width: 16, height: 16 })
      .fill('#F5F6FA')
      .offset({ x: -8 })

    Blank()

    Circle({ width: 16, height: 16 })
      .fill('#F5F6FA')
      .offset({ x: 8 })
  }
  .width('100%')
}
.width('100%')
.height(16)

预览器里一截图,立刻发现两个问题:

问题 1 :把卡片塞进 AI 聊天气泡里,气泡背景是浅蓝渐变,跟 #F5F6FA 不一致。缺口处直接露出 浅灰色块,看着像"贴上去的"。

问题 2 :外层 .shadow(...) 加在最外面的 Column 上,阴影边界还是矩形圆角。缺口边缘没有阴影包络,视觉上根本没有"凹"感。

根本原因:这两个 Circle 不是真的"挖"出去,只是用同色色块 出来的视觉错觉。一旦底色变、父容器有渐变、要截图分享、阴影需要沿轮廓走 ------ 立刻穿帮。


三、第二版尝试:Path 组件 + 双层 Stack 补阴影

既然要"真挖洞",自然想到用 SVG 路径描述一个 带缺口的形状,把卡片裁剪成这个形状。

我先画了一个带缺口的 Path(按 W=328, H=470, 缺口中线 y=144, 缺口半径 8):

text 复制代码
M 16 0                    左上圆角起点
H 312                     上边线
A 16 16 0 0 1 328 16      右上圆角(顺时针)
V 136                     右边到缺口上沿
A 8 8 0 0 0 320 144       ↘ 右缺口上半(sweep=0 内凹)
A 8 8 0 0 0 328 152       ↗ 右缺口下半
V 454                     右边继续
A 16 16 0 0 1 312 470     右下圆角
H 16                      下边
A 16 16 0 0 1 0 454       左下圆角
V 152                     左边到缺口下沿
A 8 8 0 0 0 8 144         左缺口下半
A 8 8 0 0 0 0 136         左缺口上半
V 16                      左边继续
A 16 16 0 0 1 16 0        左上圆角
Z

然后我以为:直接拿 Path() 当裁剪形状传给 .clipShape(...) 就行了。但 IDE 立刻报红 ------ clipShape 不接受绘制组件 Path

继续凭直觉猜:那就 双层 Stack ,底层一个 Path.shadow(...) 出阴影,上层一个 Column 裁成相同形状,这样:

  • 阴影 = 底层 Path 投影
  • 内容 = 上层 Column 真裁剪

写完发现这个方向也是错的:

  1. new Path({ commands }) 这种写法在新版本 ArkUI 里不推荐,提示用 PathShape
  2. 即使勉强跑起来,底层 Path 的阴影只在矩形外缘附近,缺口处还是糊的
  3. 双层 Stack 要保证宽高严格一致,一旦内容尺寸变化两层就错位

显然方向错了。问题不出在"双层结构",出在我对 ArkUI 渲染管线的假设是错的。


四、对照团队成熟做法,4 个非显然真相

公司项目里已经有一张样式几乎一样的"确认行程卡",我把它的核心实现抠出来对照,发现 4 件事是我一开始凭经验 猜错 的。

真相 1:clipShape 入参用 PathShape,不是 Path

是什么 用在哪
Path 绘制组件(视图节点) 放在视图树里画线 / 形状
PathShape Shape 对象 作为 .clipShape(...) / .mask(...) 的入参

两个名字几乎一样,文档分散在"绘制组件"和"通用属性 → 形状裁剪"两个章节,第一次写很容易混。

正确写法:

ts 复制代码
import { PathShape } from '@kit.ArkUI'

Column() { /* 内容 */ }
  .clipShape(new PathShape({ commands: this.getCardPath() }))

同套还有 CircleShapeRectShapeEllipseShape,遇到圆形 / 矩形裁剪也是用 XxxShape

真相 2:shadow 自动沿 clipShape 后的轮廓外发光

我之前想当然 ------ "clip 会把 shadow 一起裁掉,所以要双层"。

这是 Web / RN / SwiftUI 的经验。在 ArkUI 里反过来

ts 复制代码
Column() { /* 内容 */ }
  .clipShape(new PathShape({ commands: '...' }))     // ① 先裁
  .shadow({ radius: 14, color: '#22000000', offsetX: 0, offsetY: 6 })  // ② 后影

ArkUI 的 .shadow 是渲染管线合成阶段加的 outer shadow,它读的是 clipShape 算出的真实轮廓。所以单层就够,阴影会自动沿凹陷弧线外发光,缺口的"凹"感被阴影衬出来。

顺序敏感.clipShape(...) 必须在 .shadow(...) 前面 调,否则 shadow 拿的是裁剪前的矩形轮廓。

真相 3:Path commands 单位是 px,UI 默认 vp

这条踩得最莫名其妙 ------ 同样的代码在我自己的设备上看着对,换一个 DPI 不一样的设备,缺口就偏离了抬头底边。

查了一下:

  • onAreaChange 回调里 n.width / n.height 单位是 vp
  • Path commands 字符串里的数字 单位是 px

ArkUI 同时有两套单位(vp = 逻辑像素,px = 物理像素),布局 / 属性走 vp,绘制层走 px。所有进 path 的尺寸都要 vp2px(...) 转换:

ts 复制代码
const r = vp2px(this.cornerRadius)    // 16vp → px
const nr = vp2px(this.notchRadius)    // 9vp → px

真相 4:撕线可以是"零体积 + top dashed border"

我一开始用 Line + margin({ left: 12, right: 12 }) 手动给撕线两端留空,让它别撞到缺口。其实有更优雅的写法:

ts 复制代码
Row()
  .width('100%')
  .height(0)
  .border({
    width: { top: 1 },
    color: '#1A000000',
    style: BorderStyle.Dashed
  })

高度为 0 + 1px top dashed border = 零体积节点 。它会被外层的 clipShape 自动裁到缺口之间 ------ 不用手动算 margin,缺口宽度怎么改它都自适应。


五、两个工程兜底必须写

光知道 4 个 API 还不够。不同卡片高度、不同首帧时序下,会冒出两类异常。

兜底 1:首帧 0 尺寸 → 卡片消失

onAreaChange 是布局完成后才回调的。卡片第一次绘制时 cardWidth / cardHeight 还是 0,如果 getCardPath() 直接用 0 算路径,会算出 0 大小的形状 ------ 整张卡片瞬间消失,下一帧才显出来,肉眼能感到一闪。

兜底:首帧返回一个超大矩形,等效于不裁剪

ts 复制代码
if (w <= 0 || h <= 0) {
  return 'M0 0 L2000 0 L2000 2000 L0 2000 Z'
}

兜底 2:卡片太矮 → 路径自交

如果卡片高度小于 2 * (圆角半径 + 缺口半径),缺口的上下竖边长度会变成负数,path 自交,渲染异常。

兜底:太矮就退化成 无缺口圆角矩形,反正这种尺寸下缺口也没意义。

ts 复制代码
const minNy = r + nr
const maxNy = h - r - nr
if (maxNy <= minNy) {
  return /* 无缺口圆角矩形路径 */
}

还要钳制缺口中线 ny,必须落在 [minNy, maxNy] 之间,否则上下竖边会反向:

ts 复制代码
let ny = this.notchY > nr ? this.notchY : h / 2
if (ny < minNy) ny = minNy
if (ny > maxNy) ny = maxNy

六、缺口位置不要写死,让它"贴着抬头底边走"

如果把缺口 y 坐标写死,抬头里多一行文字 / 换语言 / 字号变化,缺口都会错位。

正确做法是给抬头区也加一个 onAreaChange,测出抬头实际高度:

ts 复制代码
@Local headerH: number = 0   // 抬头高,vp,给背景层定高
@Local notchY: number = 0    // 缺口中线 y,px,给路径用

// TicketHeader 最外层 Column
.onAreaChange((_o: Area, n: Area) => {
  this.headerH = Number(n.height)
  this.notchY = vp2px(Number(n.height))   // 缺口中线 = 抬头底边
})

抬头改任何内容,缺口都跟着走。


七、完整实现

把上面所有点拼起来,最终代码长这样。

7.1 字段 + 常量

ts 复制代码
import { TripCard } from '../models/chatModel'
import { PathShape } from '@kit.ArkUI'

@ComponentV2
export struct ConfirmTripCardComp {
  @Param @Require card: TripCard

  @Local cardWidth: number = 0     // px
  @Local cardHeight: number = 0    // px
  @Local notchY: number = 0        // 缺口中心 y,px
  @Local headerH: number = 0       // 抬头高,vp

  private readonly cornerRadius: number = 16
  private readonly notchRadius: number = 9
}

7.2 路径生成函数

ts 复制代码
getCardPath(): string {
  const w = this.cardWidth
  const h = this.cardHeight

  // 兜底 1:首帧 0 尺寸 → 返回大矩形 = 等效不裁剪
  if (w <= 0 || h <= 0) {
    return 'M0 0 L2000 0 L2000 2000 L0 2000 Z'
  }

  const r = vp2px(this.cornerRadius)
  const nr = vp2px(this.notchRadius)
  const minNy = r + nr
  const maxNy = h - r - nr

  // 兜底 2:太矮放不下缺口 → 退化为无缺口圆角矩形
  if (maxNy <= minNy) {
    return `M ${r} 0 H ${w - r} A ${r} ${r} 0 0 1 ${w} ${r}`
      + ` V ${h - r} A ${r} ${r} 0 0 1 ${w - r} ${h}`
      + ` H ${r} A ${r} ${r} 0 0 1 0 ${h - r}`
      + ` V ${r} A ${r} ${r} 0 0 1 ${r} 0 Z`
  }

  // 钳制缺口中线
  let ny = this.notchY > nr ? this.notchY : h / 2
  if (ny < minNy) ny = minNy
  if (ny > maxNy) ny = maxNy

  // 4 圆角 + 两侧半圆缺口(sweep=0 内凹)
  return `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`
}

7.3 外层结构(4 个关键属性)

ts 复制代码
build() {
  Stack({ alignContent: Alignment.TopStart }) {
    Column() {
      // 抬头区
      this.TicketHeader()

      // 撕线:高 0 + dashed top border
      Row()
        .width('100%')
        .height(0)
        .border({
          width: { top: 1 },
          color: '#1A000000',
          style: BorderStyle.Dashed
        })

      // 白色区:地图 / 价格 / 按钮
      Column() { /* ... */ }
    }
    .width('100%')
    .backgroundColor(Color.White)
    // ① 先裁形状
    .clipShape(new PathShape({ commands: this.getCardPath() }))
    // ② 后加阴影 → 沿凹陷外发光
    .shadow({
      radius: 14,
      color: '#22000000',
      offsetX: 0,
      offsetY: 6
    })
    // ③ 测真实尺寸 → 触发路径重算
    .onAreaChange((_o: Area, n: Area) => {
      this.cardWidth = vp2px(Number(n.width))
      this.cardHeight = vp2px(Number(n.height))
    })
  }
  .width('100%')
}

7.4 抬头:渐变 + 缺口位置回填

ts 复制代码
@Builder
TicketHeader() {
  Stack() {
    // 背景层:蓝底 + 横向白→透渐变
    Column()
      .width('100%')
      .height(this.headerH)
      .backgroundColor('#FF00B5D9')
      .linearGradient({
        angle: 90,
        colors: [['#FFFFFFFF', 0.0], ['#00FFFFFF', 1.0]]
      })

    // 内容层 + 纵向半透白→白渐变
    Column({ space: 20 }) {
      // 标题 + 黄色高亮
      Stack({ alignContent: Alignment.BottomStart }) {
        Row().width(84).height(8).backgroundColor('#FFD91A').borderRadius(4)
        Text('确认行程')
          .fontSize(20).lineHeight(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E6000000')
      }

      // 起终点三行
      Column() { /* 省略具体行 */ }
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 20 })
    .linearGradient({
      angle: 180,
      colors: [['#B3FFFFFF', 0.0], ['#FFFFFFFF', 1.0]]
    })
    // ④ 缺口位置 = 抬头底边
    .onAreaChange((_o: Area, n: Area) => {
      this.headerH = Number(n.height)
      this.notchY = vp2px(Number(n.height))
    })
  }
  .width('100%')
}

八、一个图理解整条数据流

text 复制代码
父布局给宽高
    ↓
[Column] onAreaChange 回调
    ↓
cardWidth / cardHeight (px) 更新
                                      ┐
[TicketHeader] onAreaChange 回调       │  @Local 字段变化
    ↓                                  │  → ArkUI 触发重渲
headerH (vp) / notchY (px) 更新        ┘
    ↓
build() 重新执行
    ↓
getCardPath() 用最新尺寸生成 commands
    ↓
new PathShape({ commands }) 给 clipShape
    ↓
渲染管线:
  1. 按 PathShape 裁剪内容
  2. 沿裁剪后的真实轮廓画 shadow
  3. 撕线(零体积节点)自动被裁到缺口之间

整条链路里没有一个写死的尺寸。卡片宽度由父布局决定、缺口位置由抬头决定、撕线长度由 clipShape 决定 ------ 整体响应式。


九、一句话记忆法

text 复制代码
ClipShape 用 PathShape,
Shadow 同层跟 Clip 走,
Path 用 px,UI 用 vp,
首帧太矮要兜底,
撕线高 0 加 dashed border,
缺口贴着抬头底边走。

十、回头看第一版错在哪

回过头看第一版"Circle 覆盖底色"的做法,错的不是"没用对 API",而是 思路本身

  • "假缺口"思路 → 缺口只是视觉错觉,没法承载阴影、没法适应不同底色、不可截图分享
  • "真挖洞"思路 → 缺口是物理事实,shadow / 撕线 / 子节点都自然受其影响

ArkUI 提供 clipShape + PathShape 是为了让 形状本身变成布局的一部分。一旦想清楚这件事,所有 API 都顺理成章:

  • 为什么是 PathShape 而不是 Path?因为它是参与"形状计算"的,不参与视图树
  • 为什么 shadow 自动沿凹陷走?因为合成阶段已经知道真实轮廓
  • 为什么撕线零体积就够?因为 clipShape 会替我们裁
  • 为什么要 onAreaChange + vp2px?因为形状参数走绘制层

十一、参考

相关推荐
MemoriKu1 小时前
Flutter 相册 APP 视频模态稳定化实战:从视频抽帧、Embedding 元数据到 Android 真机启动修复
android·开发语言·前端·flutter·架构·音视频·embedding
Cache技术分享1 小时前
432. Java 日期时间 API - 时间工具 TemporalQuery 详解
前端·后端
假如让我当三天老蒯1 小时前
暂时性死区是否和闭包是相背的呢(自学用)
前端·javascript
渣波1 小时前
前端开发主页面小技巧
前端·javascript
柯克七七1 小时前
我用3个周末重构了公司的前端项目,老板没发现,但同事都来找我要代码了
前端
bonechips2 小时前
JS:同步与异步,从单线程到 Promise 的编程之路
前端·javascript
如果超人不会飞2 小时前
TinyVue Pager分页组件使用指南
前端·vue.js
看谷秀2 小时前
Git笔记
前端
Aolith2 小时前
从 Pinia 到 Zustand:我在 React 里复刻了一套用户状态管理
前端·react.js·typescript