HarmonyOS技术精讲-ArkGraphics 2D(方舟2D图形服务)第七篇:动效来袭------与ArkUI结合实现2D动画与交互

Canvas动画加上交互,难在哪
ArkUI 里做 Canvas 动画,大多数人第一次写都会遇到两个问题:状态同步和触摸坐标转换。
状态同步指的是动画循环里的变量(比如旋转角度)如何驱动 Canvas 重新绘制,同时又不让整个组件频繁重建。触摸坐标转换则是手指在屏幕上滑动时,如何准确映射到 Canvas 的绘制坐标系。
这两个问题单独看都不复杂,但放在一起处理时,生命周期管理、动画启停、交互响应这些细节很容易出 bug。官方示例通常只展示静态绘制或者简单动画,一旦掺入用户交互,代码就需要额外处理不少边界情况。
这篇文章用一个可拖拽旋转的风车作为案例,把状态管理、动画循环、触摸交互这三块内容串起来讲。风车叶片持续旋转,用户拖拽时改变旋转方向和速度,松手后带惯性减速。这个场景刚好覆盖了 ArkGraphics 2D 与 ArkUI 交互的核心技术点。
它解决什么问题
这个案例的目标是:在 ArkUI 中实现一个连续动画的 2D 图形,并且用户可以通过触摸直接控制动画状态。
适用场景包括:
- 需要用户手势操控的图形化控件(如旋钮、转盘)
- 带有惯性效果的 UI 组件(如滑动选择器)
- 游戏中的拖拽旋转元素
不适用场景:
- 纯静态图形展示(不需要动画循环)
- 复杂 3D 场景(应使用 WebGL 或专用引擎)
与直接用 ArkUI 的动画属性(如 animation)相比,Canvas 方式有几个明显差异:
| 特性 | Canvas + requestAnimationFrame | ArkUI animation 属性 |
|---|---|---|
| 绘制自由度 | 任意 2D 图形,无组件限制 | 仅支持组件级属性动画 |
| 交互控制 | 可精细控制每帧状态 | 通过交互事件触发动画 |
| 性能开销 | 逐帧绘制,GPU 友好 | 组件属性变化触发重绘 |
| 惯性/物理模拟 | 容易实现 | 需要额外计算 |
这个案例选择 Canvas 方案,主要是因为风车的叶片形状(扇形)在 ArkUI 原生组件中无法直接实现,而 Canvas 可以自由绘制。同时,拖拽旋转和惯性效果需要逐帧控制角度,这正是 requestAnimationFrame 擅长的领域。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
核心实现
整个功能分解为四个部分:状态管理、Canvas 绘制、动画循环、触摸交互。下面逐个实现。
状态管理
风车的核心状态是旋转角度 和角速度。角度决定当前绘制位置,角速度控制惯性旋转。
typescript
// 风车状态
@State currentAngle: number = 0; // 当前旋转角度(弧度)
private angularVelocity: number = 0; // 当前角速度(弧度/帧)
private isDragging: boolean = false; // 是否正在拖拽
@State 装饰器让 currentAngle 的变化自动触发 UI 刷新。angularVelocity 和 isDragging 不需要驱动 UI,所以用 private 标记,避免不必要的状态追踪。
这里有个设计取舍:为什么不把 angularVelocity 也设为 @State?因为角速度只在动画循环中使用,不直接影响 Canvas 绘制内容。如果把它也设为响应式状态,每次修改都会触发组件更新检查,反而增加开销。
Canvas 绘制风车
风车由 4 个扇形叶片和一个中心圆组成。每个叶片使用 arc 绘制扇形,填充不同颜色。
typescript
// 绘制风车
private drawWindmill(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const cx = width / 2;
const cy = height / 2;
const bladeLength = 80; // 叶片长度
const bladeAngle = Math.PI / 3; // 叶片张开角度 60°
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFE66D'];
// 清空画布
ctx.clearRect(0, 0, width, height);
// 绘制背景辅助圆(淡灰色)
ctx.beginPath();
ctx.arc(cx, cy, bladeLength + 10, 0, Math.PI * 2);
ctx.strokeStyle = '#E0E0E0';
ctx.lineWidth = 1;
ctx.stroke();
// 绘制 4 个叶片
for (let i = 0; i < 4; i++) {
const startAngle = this.currentAngle + i * Math.PI / 2 - bladeAngle / 2;
const endAngle = this.currentAngle + i * Math.PI / 2 + bladeAngle / 2;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, bladeLength, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = colors[i];
ctx.fill();
// 叶片边缘描边
ctx.strokeStyle = '#333';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 绘制中心圆
ctx.beginPath();
ctx.arc(cx, cy, 14, 0, Math.PI * 2);
ctx.fillStyle = '#2C3E50';
ctx.fill();
ctx.strokeStyle = '#1A252F';
ctx.lineWidth = 2;
ctx.stroke();
// 中心小圆点
ctx.beginPath();
ctx.arc(cx, cy, 4, 0, Math.PI * 2);
ctx.fillStyle = '#FFFFFF';
ctx.fill();
}
这里需要注意:叶片的角度范围是 currentAngle 加上每个叶片的偏移量(i * π/2),再加减半个叶片张角。这样 4 个叶片正好均匀分布,整体旋转由 currentAngle 控制。
中心圆加两层是为了视觉层次感,小圆点作为装饰,也让触摸拖拽时有个视觉焦点。
动画循环
动画循环使用 requestAnimationFrame 驱动,每帧更新角度并重绘。
typescript
private animatorId: number = -1;
private startAnimation(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const animate = () => {
if (!this.isDragging && Math.abs(this.angularVelocity) > 0.001) {
// 惯性旋转:角度累加,速度衰减
this.currentAngle += this.angularVelocity;
this.angularVelocity *= 0.985; // 阻尼系数
}
this.drawWindmill(ctx, width, height);
this.animatorId = requestAnimationFrame(animate);
};
animate();
}
private stopAnimation(): void {
if (this.animatorId !== -1) {
cancelAnimationFrame(this.animatorId);
this.animatorId = -1;
}
}
惯性效果的核心逻辑:松手后 angularVelocity 逐渐衰减到零,每次衰减乘以 0.985。这个系数决定了惯性持续的时间,0.98 到 0.99 之间手感比较自然,可以根据需要调整。
示例代码地址:项目地址