请添加图片描述

前言

合同签署、物流确认、身份核验——「电子签名」是 B 端和 C 端都绕不开的场景。产品想要的往往不只是「能签」,还要:

  • 签完能回放笔迹,像真人重新写一遍;
  • 图标、勾选、进度条能用矢量路径绘制,缩放不失真;
  • 动效能控制「显示路径的哪一段」,而不是简单的淡入淡出。

HarmonyOS ArkUI 提供了 Path 路径绘制组件:通过 commands 属性传入 SVG 路径命令,可绘制任意直线、曲线和封闭图形。配合 trimStart / trimEnd 所代表的路径裁剪语义(在 declarative Path 上通过 strokeDashArray + strokeDashOffset 等效实现),可以实现 iOS 风格的「签名动画」——笔迹从起点逐渐显现,或从起点被逐段擦除。

本文结合项目 Demo,从 Path 基础、commands 语法、裁剪动画原理,到 Canvas 手写 + 同画布回放 的完整链路,做一次实战级解析。读完后,你应该能独立实现:

  1. commands 绘制复杂矢量图形;
  2. trimEnd 0→1 驱动签名书写动画;
  3. 让用户在 Canvas 上真实手写,播放时画面与手写完全一致

一、Path 组件:矢量绘制的入口

1.1 基本用法

Path 是 ArkUI 图形绘制家族的一员(同族还有 LineRectCirclePolygon 等)。核心接口:

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,需要知道路径的「逻辑总长度」。两种方式:

  1. 预估值:Demo 里对固定 commands 写死 SIGNATURE_PATH_LEN = 380,调滑块时目测对齐;
  2. 精确计算:对 commands 解析后沿各段累加弧长(复杂曲线需数值积分);
  3. 手写场景:记录每个触摸采样点,相邻点欧氏距离累加——最准确。
// 相邻采样点距离累加
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


十、延伸方向

  1. 导出签名:Canvas toDataURLPixelMap 上传服务端存证;
  2. 压感笔迹:若设备支持,根据 Touch pressure 动态 lineWidth
  3. Path 精确弧长:引入路径解析库,让 declarative Path 的 trim 动画更精准;
  4. quadraticCurveTo 平滑:手写点集后处理,回放更圆润(Canvas 2D API);
  5. 与 PDF / 合同模板合成:签名层叠加到固定坐标区域。

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

十一、总结

能力 API / 手段 典型场景
矢量路径定义 Path .commands() 图标、勾选、预设曲线
路径裁剪动画 trimStart/trimEnd → strokeDashArray 进度、书写、擦除
用户手写采集 Canvas + PanGesture 电子签名板
像素级回放 同 Canvas + 点集 + trimEnd 签名重播

一句话Path + commands 定义「画什么」;trimStart/trimEnd 定义「显示哪一段」;用户真实签名则 Canvas 写、Canvas 播,不要贸然转成 Path。

把 Demo 跑起来:默认进入「手写签名」,写几个字,点播放——你会看到自己的笔迹在原位被重新「写」一遍。这就是路径绘制与动画在鸿蒙上的完整闭环。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐