新特性:路径绘制与动画 —— Path 组件实现手绘风格动效

文章目录
前言
合同签署、物流确认、身份核验——「电子签名」是 B 端和 C 端都绕不开的场景。产品想要的往往不只是「能签」,还要:
- 签完能回放笔迹,像真人重新写一遍;
- 图标、勾选、进度条能用矢量路径绘制,缩放不失真;
- 动效能控制「显示路径的哪一段」,而不是简单的淡入淡出。
HarmonyOS ArkUI 提供了 Path 路径绘制组件:通过 commands 属性传入 SVG 路径命令,可绘制任意直线、曲线和封闭图形。配合 trimStart / trimEnd 所代表的路径裁剪语义(在 declarative Path 上通过 strokeDashArray + strokeDashOffset 等效实现),可以实现 iOS 风格的「签名动画」——笔迹从起点逐渐显现,或从起点被逐段擦除。
本文结合项目 Demo,从 Path 基础、commands 语法、裁剪动画原理,到 Canvas 手写 + 同画布回放 的完整链路,做一次实战级解析。读完后,你应该能独立实现:
- 用
commands绘制复杂矢量图形; - 用
trimEnd0→1 驱动签名书写动画; - 让用户在 Canvas 上真实手写,播放时画面与手写完全一致。
一、Path 组件:矢量绘制的入口
1.1 基本用法
Path 是 ArkUI 图形绘制家族的一员(同族还有 Line、Rect、Circle、Polygon 等)。核心接口:
Path(options?: PathOptions)
.commands('M 0 0 L 100 50')
.fillOpacity(0)
.stroke('#2563EB')
.strokeWidth(3)
.strokeLineCap(LineCapStyle.Round)
PathOptions 三个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
width |
Length | 路径所在矩形宽度 |
height |
Length | 路径所在矩形高度 |
commands |
ResourceStr | SVG 路径命令字符串 |
注意:commands 中的坐标单位是 px,与布局常用的 vp 不同。这是后面「Canvas 转 Path 画面不一致」踩坑的根源之一。
1.2 描边 vs 填充
手绘风格动效通常只要描边,不要填充:
Path({ width: 340, height: 120 })
.commands('M 24 88 Q 48 28 72 72 T 132 48')
.fillOpacity(0) // 不填充
.stroke('#1E40AF') // 描边颜色
.strokeWidth(3)
.strokeLineCap(LineCapStyle.Round) // 圆头端点 → 笔迹质感
.strokeLineJoin(LineJoinStyle.Round) // 圆角连接
.antiAlias(true) // 抗锯齿
strokeLineCap(Round) 是关键:没有它,路径端点是方头(Butt),签名动画会像「折线戳点」,而不是流畅笔迹。
1.3 与 Shape 容器的关系
多个图形可以包在 Shape 父组件里,共享 viewPort 和通用描边属性:
Shape() {
Rect().width(300).height(50)
Path().width(300).height(10).commands('M0 0 L900 0').offset({ x: 0, y: 120 })
}
.viewPort({ x: -2, y: -2, width: 304, height: 130 })
.stroke(Color.Black)
.strokeWidth(4)
单独使用 Path 更简单;需要 SVG 式多图形组合时用 Shape。
二、commands:SVG 路径命令速查
commands 接受标准 SVG path data 字符串。常用命令:
| 命令 | 名称 | 参数 | 说明 |
|---|---|---|---|
| M | moveto | x y | 移动画笔(新子路径起点) |
| L | lineto | x y | 直线到 (x,y) |
| H | horizontal | x | 水平线 |
| V | vertical | y | 垂直线 |
| Q | quadratic | x1 y1 x y | 二次贝塞尔曲线 |
| C | cubic | x1 y1 x2 y2 x y | 三次贝塞尔曲线 |
| S | smooth cubic | x2 y2 x y | 简写三次贝塞尔 |
| T | smooth quad | x y | 简写二次贝塞尔 |
| A | arc | rx ry rot large sweep x y | 椭圆弧 |
| Z | closepath | — | 闭合路径 |
2.1 直线与封闭图形
// 水平线
.commands('M0 0 L600 0')
// 三角形
.commands('M100 0 L200 240 L0 240 Z')
// 矩形(用 H/V 简写)
.commands('M0 0 H200 V200 H0 Z')
2.2 曲线:模拟手写笔迹
真实签名很少是直线段拼接,贝塞尔曲线更自然:
// 二次贝塞尔 + 平滑延续(T 命令)
const SIGNATURE = 'M 24 88 Q 48 28 72 72 T 132 48 T 192 82 T 252 38 T 312 78'
// 三次贝塞尔 + 对称延续(S 命令)
const FLOW = 'M 20 70 C 55 15 95 115 130 55 S 200 20 240 75 S 300 110 330 45'
Demo 中「commands 语法」场景展示了 M/L/Q/C/Z 四类基础图形的并排对比,建议亲手改 commands 字符串观察效果——这是理解 Path 最快的路径。
三、trimStart 与 trimEnd:路径裁剪动画的语义
3.1 概念
在 iOS Core Animation 和部分矢量动画框架里,trimPathStart / trimPathEnd 表示:
- 取值范围:[0, 1]
- 含义:路径总长度上的裁剪起止比例
trimStart = 0, trimEnd = 0→ 不可见trimStart = 0, trimEnd = 1→ 完整显示- 动画:
trimEnd从 0 增到 1 → 签名书写效果 - 动画:
trimStart从 0 增到 1 → 擦除效果
HarmonyOS Path 组件在部分版本/SDK 中尚未暴露同名链式属性,但裁剪语义完全可以通过 strokeDashArray + strokeDashOffset 等效实现。
3.2 strokeDashArray 映射公式
设路径总长度为 L,裁剪区间为 [trimStart, trimEnd](均为 0~1):
function buildTrimDash(totalLen: number, trimStart: number, trimEnd: number) {
const start = Math.min(Math.max(trimStart, 0), 1)
const end = Math.min(Math.max(trimEnd, 0), 1)
const visible = (Math.max(end, start) - start) * totalLen
return {
dashArray: [visible, totalLen],
dashOffset: start * totalLen
}
}
直觉理解:
完整路径: ●━━━━━━━━━━━━━━━━━━━━━━●
trimEnd=0.5:●━━━━━━━━━━●
trimStart=0.3, trimEnd=0.8: ●━━━━━━━●
应用到 Path:
Path({ width: 340, height: 120 })
.commands(pathData)
.fillOpacity(0)
.stroke('#7C3AED')
.strokeWidth(3)
.strokeLineCap(LineCapStyle.Round)
.strokeDashArray(buildTrimDash(pathLen, trimStart, trimEnd).dashArray)
.strokeDashOffset(buildTrimDash(pathLen, trimStart, trimEnd).dashOffset)
3.3 路径总长度怎么来?
strokeDashArray 的数值单位是 vp,需要知道路径的「逻辑总长度」。两种方式:
- 预估值:Demo 里对固定
commands写死SIGNATURE_PATH_LEN = 380,调滑块时目测对齐; - 精确计算:对
commands解析后沿各段累加弧长(复杂曲线需数值积分); - 手写场景:记录每个触摸采样点,相邻点欧氏距离累加——最准确。
// 相邻采样点距离累加
const dx = px - last.x
const dy = py - last.y
userPathLen += Math.sqrt(dx * dx + dy * dy)
四、animateTo 驱动 trimEnd:签名动画的时间轴
4.1 基本写法
@State trimEnd: number = 0
playSignatureAnim() {
this.trimStart = 0
this.trimEnd = 0
this.getUIContext()?.animateTo(
{ duration: 2400, curve: Curve.EaseInOut },
() => { this.trimEnd = 1 }
)
}
animateTo 会在 2400ms 内将 trimEnd 从当前值插值到 1。配合 @Watch 可在每一帧更新 UI:
@State @Watch('onTrimEndChanged') trimEnd: number = 0
onTrimEndChanged() {
if (this.padMode === PadMode.PLAY) {
this.redrawPartialCanvas(this.trimEnd)
}
}
4.2 擦除动画:trimStart 0→1
// 初始:完整显示
this.trimStart = 0
this.trimEnd = 1
this.getUIContext()?.animateTo(
{ duration: 1800, curve: Curve.Linear },
() => { this.trimStart = 1 } // 可见区间 [trimStart, trimEnd] 缩短至空
)
Demo「擦除动画」场景即此模式:笔迹从起点被「吃掉」,适合撤销、删除类交互。
4.3 动画时长与路径长度
固定 2 秒播完 50px 和 500px 的路径,后者会显得「写太快」。Demo 采用动态时长:
const duration = Math.min(3000, Math.max(1200, userPathLen * 8))
短签名约 1.2 秒,长签名最多 3 秒,体感更自然。
五、Canvas 手写:从用户手指到坐标序列
Path 的 commands 适合已知路径的 declarative 绘制。若要让用户自己写签名,需要 Canvas + 手势 采集笔迹。
5.1 架构
┌─────────────────────────────────────┐
│ PanGesture 捕获 localX / localY │
│ ↓ │
│ appendDrawPoint → drawPoints[] │
│ ↓ │
│ Canvas lineTo 实时绘制 │
│ ↓ │
│ 同步生成 commands 字符串(M/L) │
└─────────────────────────────────────┘
5.2 核心代码
private settings = new RenderingContextSettings(true)
private context = new CanvasRenderingContext2D(this.settings)
private panOption = new PanGestureOptions({ direction: PanDirection.All, distance: 1 })
setupCanvasStyle() {
this.context.lineWidth = 3
this.context.strokeStyle = '#1E40AF'
this.context.lineCap = 'round'
this.context.lineJoin = 'round'
}
appendDrawPoint(x: number, y: number, newStroke: boolean) {
const px = Math.round(x)
const py = Math.round(y)
if (newStroke) {
// 新笔画:记录断点 + M 命令
if (this.drawPoints.length > 0) {
this.strokeBreakIndices.push(this.drawPoints.length)
}
this.userCommands += `M ${px} ${py} `
this.drawPoints.push({ x: px, y: py })
this.context.beginPath()
this.context.moveTo(px, py)
return
}
// 延续笔画:累加路径长 + L 命令 + 实时 lineTo
const last = this.drawPoints[this.drawPoints.length - 1]
this.userPathLen += Math.sqrt((px - last.x) ** 2 + (py - last.y) ** 2)
this.userCommands += `L ${px} ${py} `
this.drawPoints.push({ x: px, y: py })
this.context.lineTo(px, py)
this.context.stroke()
}
5.3 多笔画支持
用户签名常有多笔(姓一笔、名一笔)。每次 onActionStart 视为新笔画:
strokeBreakIndices记录每笔起始下标,如[0, 45, 102]- 回放时按笔画分段
beginPath/stroke,避免不同笔画被错误连线
5.4 有效性校验
const MIN_PATH_LEN = 30
finalizeSignature() {
this.hasSignature = this.userCommands.length > 0 && this.userPathLen >= MIN_PATH_LEN
}
防止用户轻点一下误触「播放」。
六、关键决策:为什么回放用 Canvas,而不是 Path?
第一版 Demo 曾尝试:手写完成后,切换到 Path 组件 + strokeDashArray 播放动画。
用户反馈:播放的不是自己写的画面。
根因是 坐标系与单位不一致:
| 环节 | 坐标系 | 单位 |
|---|---|---|
Canvas localX/localY |
组件本地坐标 | vp |
Path commands |
路径矩形内坐标 | px |
同一组数字 (100, 50),在 Canvas 上是 vp 空间的位置,传给 Path 会被当作 px 解析——在不同 DPI 设备上偏移、缩放都可能不同。多笔画、圆头端点的渲染管线也不完全一致。
6.1 正确方案:同 Canvas 逐段重绘
书写和播放在同一块 Canvas 上,回放时根据 trimEnd 比例,从 drawPoints[] 重放坐标:
redrawPartialCanvas(progress: number) {
this.clearCanvasSurface()
const targetLen = this.userPathLen * Math.min(Math.max(progress, 0), 1)
let consumed = 0
for (let s = 0; s < this.strokeBreakIndices.length; s++) {
const startIdx = this.strokeBreakIndices[s]
const endIdx = s + 1 < this.strokeBreakIndices.length
? this.strokeBreakIndices[s + 1] : this.drawPoints.length
this.context.beginPath()
this.context.moveTo(this.drawPoints[startIdx].x, this.drawPoints[startIdx].y)
for (let i = startIdx + 1; i < endIdx; i++) {
const prev = this.drawPoints[i - 1]
const curr = this.drawPoints[i]
const segLen = segmentLength(prev, curr)
if (consumed + segLen <= targetLen) {
this.context.lineTo(curr.x, curr.y)
consumed += segLen
} else if (consumed < targetLen) {
// 最后一段可能只画一部分(线性插值)
const t = (targetLen - consumed) / segLen
this.context.lineTo(
prev.x + (curr.x - prev.x) * t,
prev.y + (curr.y - prev.y) * t
)
break
}
}
this.context.stroke()
if (consumed >= targetLen) break
}
}
优势:
- 像素级一致:播放的就是用户写的那份;
- 不依赖 Path 的 px/vp 转换;
trimEnd动画语义不变,只是渲染载体从 Path 换成 Canvas。
何时仍用 Path?
- 静态图标、预设曲线动画(commands 已知、不需用户输入);
- 需要与 Shape 组合、或导出为矢量资源;
- 配合
strokeDashArray做裁剪区间 Demo(不涉及用户手写坐标)。
七、完整交互流程
Demo 中「手写签名」场景的状态机:
┌──────────┐
│ DRAW │ 用户 PanGesture 书写
│ 手写模式 │
└────┬─────┘
│ 点击「播放动画」
▼
┌──────────┐
│ PLAY │ trimEnd: 0 → 1
│ 回放模式 │ redrawPartialCanvas 逐帧重绘
└────┬─────┘
│
┌────────┴────────┐
│ │
「重播动画」 「重新签名」
trimEnd 再来 reset → DRAW
7.1 模式切换
enum PadMode { DRAW = 0, PLAY = 1 }
enterPlayMode() {
if (!this.hasSignature) return
this.padMode = PadMode.PLAY
this.trimEnd = 0
this.redrawPartialCanvas(0) // 清空画布
this.playUserSignatureAnim()
}
enterDrawMode() {
this.resetSignaturePad() // 清空点集,回到手写
}
PLAY 模式下 PanGesture 仍挂载,但 onActionStart 首行判断 padMode !== DRAW 直接 return——避免播放时误触改写。
7.2 用户操作清单
| 操作 | 模式 | 效果 |
|---|---|---|
| 手指滑动 | DRAW | 实时画线 + 记录坐标 |
| 清除 | DRAW | 清空画布与点集 |
| 播放动画 | DRAW→PLAY | trimEnd 动画回放 |
| 重播动画 | PLAY | 再次 0→1 |
| 重新签名 | PLAY→DRAW | 全部重置 |
八、Demo 四场景对照
| 场景 | 渲染载体 | 动画驱动 | 用途 |
|---|---|---|---|
| 手写签名 | Canvas | trimEnd + 点集重绘 | 用户真实手写 + 回放 |
| 裁剪区间 | Path | trimStart/trimEnd 滑块 | 理解裁剪区间概念 |
| commands 语法 | Path | trimEnd 滑块(静态路径) | 学习 SVG 命令 |
| 擦除动画 | Path | trimStart 0→1 | 反向动画 |
前两个场景解决「用户手写 + 动画」产品需求;后三个场景是 Path declarative 能力的教学补充。
九、踩坑清单
9.1 Canvas 转 Path 画面不一致
现象:播放动画时签名位置偏移、大小不对。
原因:vp vs px 单位混用。
解法:用户手写回放走 Canvas;Path 仅用于预设路径。
9.2 动画播完笔迹缺一段
现象:trimEnd 到 1 了,末尾还有一小截没显示。
原因:userPathLen 估算偏小,或 strokeDashArray 与真实弧长不匹配。
解法:手写场景用采样点累加;固定 Path 用实测或调参;Canvas 回放用 redrawPartialCanvas(1) 兜底。
9.3 多笔画被连成一条
现象:回放时「张」的最后一笔连到「三」的第一笔。
原因:未记录 strokeBreakIndices,全局一个 beginPath。
解法:每次 onActionStart 新笔画,断点数组分段 stroke。
9.4 animateTo 期间 @Watch 不触发
现象:trimEnd 变了但 Canvas 不刷新。
原因:未加 @Watch,或 padMode 判断漏掉。
解法:@State @Watch('onTrimEndChanged') trimEnd,回调里判断 padMode === PLAY。
9.5 笔迹太稀疏/太密
现象:回放像折线或卡顿。
原因:PanGesture 采样点过少;或 distance: 1 未设导致首点丢失。
解法:PanGestureOptions({ direction: PanDirection.All, distance: 1 });必要时对点集做 Douglas-Peucker 简化或贝塞尔平滑(进阶)。
9.6 fill 与 stroke 混淆
现象:路径动画变成「色块填充」而非「线条书写」。
解法:签名类动效统一 fillOpacity(0) + stroke。
十、延伸方向
- 导出签名:Canvas
toDataURL或PixelMap上传服务端存证; - 压感笔迹:若设备支持,根据
Touchpressure 动态lineWidth; - Path 精确弧长:引入路径解析库,让 declarative Path 的 trim 动画更精准;
- quadraticCurveTo 平滑:手写点集后处理,回放更圆润(Canvas 2D API);
- 与 PDF / 合同模板合成:签名层叠加到固定坐标区域。



十一、总结
| 能力 | API / 手段 | 典型场景 |
|---|---|---|
| 矢量路径定义 | Path .commands() |
图标、勾选、预设曲线 |
| 路径裁剪动画 | trimStart/trimEnd → strokeDashArray | 进度、书写、擦除 |
| 用户手写采集 | Canvas + PanGesture | 电子签名板 |
| 像素级回放 | 同 Canvas + 点集 + trimEnd | 签名重播 |
一句话:Path + commands 定义「画什么」;trimStart/trimEnd 定义「显示哪一段」;用户真实签名则 Canvas 写、Canvas 播,不要贸然转成 Path。
把 Demo 跑起来:默认进入「手写签名」,写几个字,点播放——你会看到自己的笔迹在原位被重新「写」一遍。这就是路径绘制与动画在鸿蒙上的完整闭环。
更多推荐



所有评论(0)