ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

HarmonyOS Canvas动画实战:用ArkTS实现平行四边形面积推导可视化

HarmonyOS Canvas动画实战:用ArkTS实现平行四边形面积推导可视化 数学课上讲平行四边形面积教材都会配一幅“沿高剪开、平移拼合”的插图。图是静态的很多学生盯着看半天也不明白为什么三角形一动过去就变成了长方形。我这个HarmonyOS应用实例就是把这幅静态插图做成动画——用 ArkTS Canvas 在屏幕上画出一个平行四边形沿高线切下左侧三角形再平移拼到右侧让学生亲眼看着它变成矩形。实例本身不复杂但把几何变换、Canvas 绘制、动画状态管理串在了一起很适合作为 HarmonyOS NEXTAPI 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 30oy 根据 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 StudioHarmonyOS NEXT SDK API 125.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 固定 300Canvas 高 360底边离底部留 60公式文字可以画在底部区域。3.2 绘制静态图形与辅助线绘制平行四边形实际上就是画一条路径moveTo AlineTo BlineTo ClineTo DclosePath然后 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 直接置 0Canvas 通过 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 动画、几何坐标变换四大技能。后面无论是做数学课件、物理模拟还是数据可视化都能直接复用这套模式。我在实际做的过程中最大的感受是这类教学动画的质量不在于动画炫不炫而在于几何逻辑清不清晰。坐标计算严谨、阶段切换合理、标注信息准确比加十个粒子特效都管用。把平行四边形这个实例吃透你再去写三角形、梯形甚至圆面积的转化动画会发现思路完全同构只是坐标和变换方式变了而已。
返回列表