☰
HarmonyOS Canvas动画实战:用ArkTS实现平行四边形面积推导可视化
2026/10/5 13:44:35 网站建设 项目流程

数学课上讲平行四边形面积,教材都会配一幅“沿高剪开、平移拼合”的插图。图是静态的,很多学生盯着看半天也不明白为什么三角形一动过去就变成了长方形。我这个HarmonyOS应用实例,就是把这幅静态插图做成动画——用 ArkTS + Canvas 在屏幕上画出一个平行四边形,沿高线切下左侧三角形,再平移拼到右侧,让学生亲眼看着它变成矩形。实例本身不复杂,但把几何变换、Canvas 绘制、动画状态管理串在了一起,很适合作为 HarmonyOS NEXT(API 12+)图形与动画开发的练手项目。

1. 项目解析:为什么做“平行四边形转化动画”

1.1 核心需求是什么

这个项目的原始需求可以浓缩成一句话:用动画演示平行四边形面积公式的推导过程。表面上是“画个多边形做个动画”,但拆开看,里面藏着三个层次的需求。

第一层是数学教育需求。平行四边形面积 S = 底 × 高 这个公式,学生直接背很容易,但要说清楚“为什么不是 底 × 斜边”,光靠口头讲解是不够的。割补法是教材里的标准推导:沿高把平行四边形切开,平移拼成长方形,长方形面积 = 底 × 高,于是平行四边形面积也等于 底 × 高。这中间的“切”和“移”就是动画要表现的核心动作。

第二层是可视化交互需求。静态插图的问题在于,学生看不到“切开以后三角形要怎么动”。有了动画,切割线出现、三角形分离、平移到位、拼成矩形,每一步的因果关系都摆在眼前。再加上底和高标注、公式同步更新,整个推导链条就闭环了。

第三层才是技术需求。在 HarmonyOS 里实现这个效果,要解决三个问题:怎么画平行四边形、怎么把图形拆成可独立移动的部分、怎么用动画衔接整个流程。这三个问题分别对应 Canvas 路径绘制、几何坐标计算和状态驱动的重绘机制。

1.2 方案选型:为什么用 Canvas 而不用组件动画

有人在实现时可能会想:ArkUI 有 Shape 组件,也有 animateTo 显式动画,是不是直接用组件拼图再动画更省事?

我实际对比过两种做法。Shape 组件画平行四边形、三角形、矩形拼接,然后用 translate 属性做位移动画,理论上可行。但很快会遇到问题:动画过程中图形要“从平行四边形的一部分”变成“独立的三角形”,组件树和属性状态要来回切换,逻辑很绕。而且割补过程中要绘制虚线辅助线、直角标记、文字标注,这些用 Shape 组件表达也很繁琐。

Canvas 方案的优势在于:所有图形都是坐标计算出来的,几何关系完全可控。平行四边形、三角形、矩形本质上是一组顶点坐标,切割就是计算高线的垂足,平移就是给顶点坐标加一个偏移向量。动画过程中每一帧的画面都可以由当前进度 t 插值得到,数学表达非常干净。这也是图形类教学动画的标准做法。

我给这个实例定的技术路线是:Canvas 负责全部绘制,@State 里的 progress 变量作为动画进度,animateTo 驱动进度变化,@Watch 监听进度变化并触发 Canvas 重绘。这套“状态驱动重绘”的模型,比逐帧手动调用 requestAnimationFrame 更符合 ArkUI 的声明式思路,代码也更好维护。

1.3 应用场景与扩展价值

这个实例不算大,但它是一个可复用的“教学动画模板”。同一个项目里,只要改坐标和动画拆解逻辑,就能扩展到三角形面积、梯形面积、圆面积等系列课程;只要把 Canvas 绘制换成更复杂的数据图表,又能做成物理运动轨迹演示、函数图像变换等工具。

我自己的体会是,这类动画应用特别适合三个方向的人去折腾:一是刚接触 Canvas 的 HarmonyOS 开发者,可以用它练手路径绘制和状态联动;二是做教育类应用的产品,这个实例可以直接作为课件素材的参考实现;三是对数学可视化感兴趣的开发者,这个项目的坐标计算思路能迁移到很多几何动画场景。

2. 几何本质:割补法如何把平行四边形变成矩形

2.1 面积公式推导的直观逻辑

先回到数学本身。平行四边形 ABCD,假设 A 是左下角,B 是右下角,D 是左上角,C 是右上角。因为平行四边形对边平行且相等,AD 和 BC 是两条相等的斜边,底边 AB 和顶边 DC 也是相等的水平边。

现在从 D 向底边 AB 作一条垂线,垂足记作 E;再从 C 向底边的延长线作垂线,垂足记作 F。这样就得到了一个很有意思的分解结果:平行四边形内部是一条矩形 E-F-C-D(宽度等于 AB,高度就是平行四边形的高),左侧多出一个直角三角形 A-D-E,右侧则缺少一个与它完全相同的直角三角形 B-C-F。平行四边形比矩形“多出来的角”和“缺掉的角”恰好是全等的。

所以割补法的核心逻辑是:把左侧的三角形 A-D-E 剪下来,水平向右平移,让它精确填补到右侧 B-C-F 的位置。平移之后,原来的平行四边形区域就拼成了一个完整的矩形 E-F-C-D。矩形面积 = 底 × 高,而拼合过程面积没有增减,平行四边形面积自然也是 底 × 高。

这里面最关键的直观点是:平移不改变图形的形状和大小。学生看到三角形“飞”过去之后严丝合缝地补上空缺,就会理解为什么斜边不算数——因为那条斜边在转化过程中只是被“挪了个地方”,并没有参与面积计算。

2.2 切分与平移的坐标计算

坐标设计是整个实例的核心技术环节。我先把参数固定下来,方便后面讲代码:

  • 底边长 base = 260(单位 vp)
  • 高 height = 160
  • 倾斜偏移量 shift = 70(就是这个值决定了平行四边形“歪”多少)
  • 底边左端点 A 的坐标设为 (ox, oy),其中 ox = 30,oy 根据 Canvas 高度调整

那么顶点坐标就是:

  • A = (ox, oy)
  • B = (ox + base, oy)
  • C = (ox + base + shift, oy - height)
  • D = (ox + shift, oy - height)
  • E = (ox + shift, oy)
  • F = (ox + base + shift, oy)

注意 Canvas 坐标系里 y 轴是向下的,所以“高”要体现在 oy - height 上,画出来才会在底边上方。

三个关键几何对象:

  • 矩形 E-F-C-D:四个顶点是 E、F、C、D
  • 左侧三角形 A-D-E:三个顶点是 A、D、E
  • 右侧空缺三角形 B-F-C:三个顶点是 B、F、C(动画中作为目标位置的参照)

平移向量怎么算?要让三角形 A-D-E 填补到 B-F-C 的位置,需要把它的每个顶点向右移动 base 那么多。验证一下:E 的 x 坐标是 ox + shift,加上 base 后变成 ox + base + shift,正好是 F 的 x 坐标;D 的 x 坐标是 ox + shift,加上 base 后变成 ox + base + shift,正好是 C 的 x 坐标。所以平移向量就是 (base, 0),代码里直接沿 x 方向偏移 base 即可,不需要任何旋转操作。

2.3 动画节奏设计

几何计算清楚之后,动画节奏直接决定观感。我一开始把动画做成“三角形咻地一下飞过去”,结果观感很不好——太快了,学生根本看不清图形是怎么拆出来的。后来我调整成四个阶段:

  • 阶段一(progress 0.0 ~ 0.15):显示完整平行四边形,标注底和高
  • 阶段二(progress 0.15 ~ 0.3):淡入高线 DE、辅助线 CF,标注切割位置
  • 阶段三(progress 0.3 ~ 0.8):左侧三角形向右平移,拼合到右侧空缺处
  • 阶段四(progress 0.8 ~ 1.0):整体定格成矩形,显示面积公式

每个阶段用插值函数计算中间状态。比如阶段三里,三角形的顶点在当前帧的 x 坐标 = 原始 x + base × moveT,其中 moveT 是把 0.3~0.8 线性映射到 0~1 得到的。这样整个动画是连续平滑的,不会出现突然跳变。

3. 核心实现:ArkTS + Canvas 绘制与动画

3.1 环境与组件初始化

开发环境我用的是 DevEco Studio,HarmonyOS NEXT SDK API 12(5.0.0(12)),ArkTS 语法。工程里新建一个页面,直接往 build 里放一个 Canvas 组件即可。

@Entry @Component struct ParallelogramAreaPage { private settings: RenderingContextSettings = new RenderingContextSettings(true) private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings) @State @Watch('onProgressChange') progress: number = 0 @State isAnimating: boolean = false private readonly base: number = 260 private readonly height: number = 160 private readonly shift: number = 70 private readonly ox: number = 40 private readonly oy: number = 300 build() { Column() { Text('平行四边形面积推导') .fontSize(20) .fontWeight(FontWeight.Bold) .margin({ top: 12, bottom: 8 }) Canvas(this.ctx) .width('100%') .height(360) .onReady(() => { this.drawFrame() }) Row({ space: 16 }) { Button('重置') .onClick(() => { this.progress = 0 }) Button('转化动画') .onClick(() => { this.startAnimation() }) } .margin({ top: 16, bottom: 16 }) } .width('100%') .height('100%') .backgroundColor('#F5F6FA') } }

这里有几个细节值得说。RenderingContextSettings 的构造参数 true 表示开启抗锯齿,Canvas 画斜边时边缘会平滑很多。oy 固定 300,Canvas 高 360,底边离底部留 60,公式文字可以画在底部区域。

3.2 绘制静态图形与辅助线

绘制平行四边形实际上就是画一条路径:moveTo A,lineTo B,lineTo C,lineTo D,closePath,然后 fill 和 stroke。用一个辅助函数封装多边形填充,后面矩形和三角形都能复用:

private fillPolygon(points: Point[], fillColor: string, strokeColor: string) { const ctx = this.ctx ctx.beginPath() ctx.moveTo(points[0].x, points[0].y) for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i].x, points[i].y) } ctx.closePath() ctx.fillStyle = fillColor ctx.fill() ctx.strokeStyle = strokeColor ctx.lineWidth = 2 ctx.stroke() }

需要的 Point 结构直接用普通对象{ x: number, y: number }就行,不需要额外定义类,ArkTS 下这样最干净。

辅助线用 setLineDash 画虚线。此 API 的写法是:

ctx.beginPath() ctx.setLineDash([6, 4]) ctx.moveTo(D.x, D.y) ctx.lineTo(E.x, E.y) ctx.stroke() ctx.setLineDash([])

画完必须 setLineDash([ ]) 恢复实线,否则后面所有 lineTo 都会是虚线,这是个很隐蔽的坑。直角标记可以在 E 点附近画一个小直角符号,用一个短竖线加短横线表示,或者直接用 fillText 写一个“直角符号”。

3.3 动画驱动的核心逻辑

Canvas 的 onReady 只会触发一次,之后修改 progress 不会自动重绘。所以我用了 @Watch 装饰器监听 progress,一旦进度变化就调用 drawFrame() 重绘。

@State @Watch('onProgressChange') progress: number = 0 onProgressChange() { this.drawFrame() }

动画触发函数用 animateTo 驱动状态变化:

startAnimation() { if (this.isAnimating) { return } this.isAnimating = true this.progress = 0 animateTo({ duration: 3000, curve: Curve.EaseInOut, onFinish: () => { this.isAnimating = false } }, () => { this.progress = 1 }) }

为什么用 animateTo 而不是自己写定时器?因为 animateTo 的插值和曲线控制是系统级的,EaseInOut 曲线让平移过程有加减速,比线性动画看起来更自然。onFinish 回调里把动画锁复位,避免动画过程中用户重复点击导致状态错乱。

drawFrame 是整个页面的核心函数,按阶段画不同的内容:

private drawFrame() { const ctx = this.ctx const canvasWidth = this.ctx.width const canvasHeight = this.ctx.height ctx.clearRect(0, 0, canvasWidth, canvasHeight) this.drawGrid() // 顶点定义 const A = { x: this.ox, y: this.oy } const B = { x: this.ox + this.base, y: this.oy } const C = { x: this.ox + this.base + this.shift, y: this.oy - this.height } const D = { x: this.ox + this.shift, y: this.oy - this.height } const E = { x: this.ox + this.shift, y: this.oy } const F = { x: this.ox + this.base + this.shift, y: this.oy } const p = this.progress if (p < 0.15) { // 阶段一:完整平行四边形 this.fillPolygon([A, B, C, D], '#4C89F5', '#1D4B8F') this.drawBaseLabel(A, B) this.drawHeightLabel(D, E) this.drawFormula(base, height) } else if (p < 0.3) { // 阶段二:显示切割线 this.fillPolygon([A, B, C, D], '#8BB5FF', '#1D4B8F') this.drawDashLine(D, E) this.drawDashLine(C, F) this.drawRightAngleMark(D, E, F) this.drawCutLabel(A, E, D) this.drawBaseLabel(E, F) this.drawHeightLabel(D, E) } else if (p < 0.8) { // 阶段三:三角形平移 const moveT = Math.min(1, (p - 0.3) / 0.5) const tx = this.base * moveT const movedA = { x: A.x + tx, y: A.y } const movedE = { x: E.x + tx, y: E.y } const movedD = { x: D.x + tx, y: D.y } // 中间矩形 this.fillPolygon([E, F, C, D], '#4C89F5', '#1D4B8F') // 平移中的三角形 this.fillPolygon([movedA, movedE, movedD], '#F56C4C', '#A84430') // 目标位置虚线框 this.strokeDashedPolygon([B, F, C]) // 标注 this.drawBaseLabel(E, F) this.drawHeightLabel(D, E) } else { // 阶段四:矩形定格 this.fillPolygon([E, F, C, D], '#4C89F5', '#1D4B8F') this.drawBaseLabel(E, F) this.drawHeightLabel(D, E) this.drawFormula(base, height) ctx.fillStyle = '#2E8B57' ctx.font = '18vp' ctx.fillText('平行四边形面积 = 矩形面积 = 底 × 高', 40, 350) } }

这个逻辑一开始我写成了一堆 if 嵌套,后面发现用 clamp 映射更简洁。moveT 的计算就是把阶段三的进度段归一化:(p - 0.3) / 0.5,小于 0 取 0,大于 1 取 1,这样阶段三前后三角形位置都是连续的。

3.4 交互控制与状态管理

页面下方两个按钮,“重置”把 progress 直接置 0,Canvas 通过 @Watch 自动回到初始平行四边形。“转化动画”把 progress 从 0 直接动画到 1。

实际操作中我发现一个体验问题:如果动画已经进行到一半,用户点“重置”,再把 progress 置 0,动画锁没释放,下次点击“转化动画”会被 isAnimating 挡住。所以我给重置按钮也加了锁处理:

Button('重置') .onClick(() => { this.progress = 0 this.isAnimating = false })

此外进度值驱动重绘有个细节:当 progress 直接从 0 动画到 1 的过程中,@Watch 回调里会执行完整的 drawFrame,整个 Canvas 每帧都重绘。360 × 360 的区域用 60fps 跑完全没压力,但要注意不要在 drawFrame 里做耗时操作,比如每次 new 数组、频繁设置字体。我在优化阶段把所有需要复用的颜色值、字体字符串都抽成了常量,实测动画全程丝滑。

4. 实用体验:标注绘制与效果调优

4.1 底、高标注怎么画才清楚

画教学动画,图形只是基础,标注信息才是教学价值的核心。底边标注我画在底边下方约 20vp 的位置,用一条水平短线和文字“底”组成;高标注画在 DE 线段中间靠左的位置,用箭头线指向 E 点,并标上“高”。

private drawBaseLabel(start: Point, end: Point) { const ctx = this.ctx const midX = (start.x + end.x) / 2 const labelY = start.y + 30 ctx.font = '16vp' ctx.fillStyle = '#333333' ctx.fillText('底', midX - 8, labelY) ctx.beginPath() ctx.moveTo(start.x, start.y + 20) ctx.lineTo(end.x, end.y + 20) ctx.strokeStyle = '#333333' ctx.lineWidth = 1 ctx.stroke() } private drawHeightLabel(from: Point, to: Point) { const ctx = this.ctx const labelX = from.x - 36 const midY = (from.y + to.y) / 2 ctx.font = '16vp' ctx.fillStyle = '#333333' ctx.fillText('高', labelX, midY + 5) ctx.beginPath() ctx.moveTo(from.x, from.y) ctx.lineTo(to.x, to.y) ctx.setLineDash([4, 3]) ctx.strokeStyle = '#E5733F' ctx.lineWidth = 2 ctx.stroke() ctx.setLineDash([]) }

这里把高线画在虚线辅助线旁边,文字放在左侧,颜色用橙色与主色区分。实测下来,标注文字在 Canvas 坐标中要手动估算宽高,fillText 没有居中参数,我通常用x - 8这种偏移来粗对齐,视觉上已经够用。

4.2 视觉细节优化

图形填充颜色选了三组互补色:平行四边形主色用蓝色系,平移的三角形用橙色系,辅助线用灰色虚线。三色配合让学生的目光能聚焦到“哪个部分在动”,不会被杂色干扰。

网格背景是我附加的一个小功能,浅灰色网格线能帮助学生感知图形的形状和位置变化,尤其是三角形从左边移到右边时,网格线能让“平移不改变形状”这个数学本质更直观。

private drawGrid() { const ctx = this.ctx ctx.strokeStyle = '#EEEEEE' ctx.lineWidth = 1 for (let i = 0; i < 12; i++) { ctx.beginPath() ctx.moveTo(i * 40, 0) ctx.lineTo(i * 40, 360) ctx.stroke() ctx.beginPath() ctx.moveTo(0, i * 30) ctx.lineTo(380, i * 30) ctx.stroke() } }

网格线画在 clearRect 之后、所有图形之前,这样不会被覆盖。还有一个细节是 Canvas 的宽度可能不是正好 400,我用this.ctx.width获取实际宽度来清屏,避免用硬编码。

公式文字的显示时机我也做了调整。阶段一公式只显示文字“S = 底 × 高”;阶段四中,因为图形已经转化完,公式显示加粗变颜色,并且补充一句“平行四边形面积 = 矩形面积”。前后对比让学生看到公式“被证明”的过程。

5. 踩坑记录与常见问题排查

5.1 高频问题速查表

我把实际开发中遇到的典型问题整理成一个表,基本覆盖了 Canvas 动画类应用的常见坑。

问题现象原因解决办法
Canvas 只在第一次绘制,进度变了画面不动onReady 只触发一次,progress 变化不会自动重绘用 @Watch('onProgressChange') 监听 progress,在回调里调用 drawFrame()
三角形平移到一半突然消失移动过程的顶点计算在某个阶段结束时跳变用 clamp 函数把阶段内进度限制在 0~1,保证首尾帧连续
虚线辅助线画完之后,后续形状也变成虚线setLineDash 没有复位画完虚线后立刻执行 setLineDash([ ])
动画结束后按钮无法再次点击动画锁没有在 onFinish 里释放把 isAnimating 复位放在 animateTo 的 onFinish 回调里
文字位置偏左或偏右fillText 无居中参数用 measureText 测量文字宽度,x 坐标减去宽度的一半
不同屏幕尺寸下图形位置跑偏坐标用了固定值在 onReady 里读取 ctx.width 和 ctx.height,动态计算 ox 和 oy
快速点击按钮导致动画状态混乱多次点击触发多个 animateTo动画中通过 isAnimating 拦截重复触发

5.2 几个值得记录的调试细节

我在调试阶段三的时候遇到过一个典型问题:三角形从起始位置开始移动时,第一帧会“闪”到右侧空缺处。排查发现是 moveT 的计算出了低级的除法错误——我用的是(p - 0.3) / 0.5,但忘了对结果 clamp,当 p 正好等于 0.3 时 moveT = 0,这个没问题;问题是当 p 小于 0.3 时 moveT 是负数,三角形被推到了相反方向。所以阶段三的代码里第一件事就是 Math.max(0, Math.min(1, ...))。

还有一次是在真机上测试,三角形平移结束后,矩形边缘出现一条细细的缝隙。原因是平移后的三角形坐标与矩形边缘坐标都是浮点计算,画到像素网格上可能相差半个像素。解决办法很简单:把平移后的顶点坐标用 Math.round 取整,或者在拼接处多画 0.5vp 的覆盖宽度。我用后者,因为取整会让运动过程出现小锯齿感。

关于文字,HarmonyOS Canvas 的 fillText 默认字体大小是 12vp,在 2K 屏上显得很小。我在每次 drawFrame 开始前统一设置ctx.font = '16vp',并且用ctx.textBaseline = 'middle'让文字垂直方向上居中,这样标注文字在屏幕上显示更清晰。

还有一个性能相关的坑:如果 Canvas 的背景需要渐变效果,不要用 ctx.createLinearGradient 每帧创建,会很慢。先把渐变对象存为成员变量,或者干脆像我一样用纯色填充背景。教学动画不需要花哨渐变,稳定流畅最重要。

6. 扩展方向:三角形、梯形也能做转化动画

6.1 三角形面积动画的拆解思路

做完平行四边形,最容易扩展的是锐角三角形面积。思路是取一条边的中点,过中点作另外两边的平行线,把三角形切分成一个平行四边形和两个小三角形,然后平移拼成长方形。

具体坐标计算我不展开,但核心思想一致:先找一个“可移动的三角形”,把它平移拼到另一侧,让原来的图形补齐成矩形。动画结构可以完全复用这个实例的阶段划分——先显示原图形,再显示辅助线,然后平移,最后定格矩形。

动手扩展的时候有个建议:先不要急着写代码,拿纸笔画一遍坐标,把每个顶点在“阶段三”的起点和终点列出来,确认平移向量一致,再动手改 drawFrame。几何动画的坑大多出在坐标计算,代码本身反而是最简单的。

6.2 梯形面积与更复杂图形的转化

梯形面积公式 (上底 + 下底) × 高 ÷ 2,教材上也有转化动画:沿中位线切成两个小梯形,旋转 180° 拼成一个平行四边形。这个项目里“旋转”就成了新的关键动作,仅用平移已经无法覆盖。

如果想把旋转动画也做出来,Canvas 下我推荐先平移再旋转的思路:把要旋转的图形移动到某个锚点,ctx.save(),ctx.translate(锚点x, 锚点y),ctx.rotate(角度),然后绘制,再 restore。这属于 Canvas 的坐标变换玩法,和本实例的插值平移正好互补。

从应用角度讲,平行四边形转化动画是这一类“割补法演示”的最小闭环。把这个闭环跑通,你就同时掌握了 Canvas 绘图、状态驱动重绘、animateTo 动画、几何坐标变换四大技能。后面无论是做数学课件、物理模拟还是数据可视化,都能直接复用这套模式。

我在实际做的过程中最大的感受是:这类教学动画的质量不在于动画炫不炫,而在于几何逻辑清不清晰。坐标计算严谨、阶段切换合理、标注信息准确,比加十个粒子特效都管用。把平行四边形这个实例吃透,你再去写三角形、梯形甚至圆面积的转化动画,会发现思路完全同构,只是坐标和变换方式变了而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询