码农周末:用鸿蒙 Canvas 撸了个抽奖转盘,顺便把动画原理搞明白了

周末闲着没事,寻思着学点新技术。之前一直听说鸿蒙的 Canvas API 和 Web 标准高度兼容,就想着搞个实战项目验证一下。

最后选了抽奖转盘——功能不复杂,但该有的技术点都有:Canvas绑制、动画、状态管理、用户交互。而且这玩意儿做出来还挺实用的,年会、活动都能用。


一、为什么选 Canvas?

其实鸿蒙提供了很多实现转盘的方案:

  • Image + Rotation:用图片做转盘,旋转动画
  • 自定义组件:用 ArkUI 的 Shape 组件
  • Canvas:手动绑制

为什么选 Canvas?几个原因:

  1. 灵活:想画啥画啥,不受限制
  2. 轻量:不用准备一堆图片资源
  3. 学习价值:Canvas 是基本功,学会了一通百通

二、开发环境

没啥特别的,就是 DevEco Studio + HarmonyOS NEXT (API 23)。

项目配置:

  • 包名:com.example.myapplication
  • 模板:Empty Ability
  • 语言:ArkTS

三、Canvas 基础:先画个圆

万事开头难,先画个圆试试水:

Canvas(this.canvasCtx)
  .width(310)
  .height(310)
  .onReady(() => {
    const ctx = this.canvasCtx
    ctx.beginPath()
    ctx.arc(155, 155, 138, 0, Math.PI * 2)
    ctx.fillStyle = '#FF6B6B'
    ctx.fill()
  })

运行一下,一个红色大圆出现在屏幕中央。

感觉:还行,和 Web Canvas 几乎一模一样。


四、画转盘:扇形是关键

转盘本质上就是8个扇形拼成的圆。

4.1 扇形怎么画?

关键代码就三行:

ctx.moveTo(cx, cy)           // 移动到圆心
ctx.arc(cx, cy, r, start, end)  // 画弧
ctx.closePath()              // 闭合路径

closePath() 会自动把弧的终点和圆心连起来,形成一个扇形。

4.2 画8个扇形

const colors = [
  '#FF6B6B', '#FFB347', '#4ECDC4', '#45B7D1',
  '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'
]

for (let i = 0; i < 8; i++) {
  const start = i * Math.PI / 4
  const end = start + Math.PI / 4
  
  ctx.beginPath()
  ctx.moveTo(155, 155)
  ctx.arc(155, 155, 138, start, end)
  ctx.closePath()
  ctx.fillStyle = colors[i]
  ctx.fill()
}

效果:一个彩色的大饼出现在屏幕上。

4.3 文字怎么写?

这个问题困扰了我一会儿。每个扇形的文字方向不一样,总不能都横着写吧?

后来发现 Canvas 有坐标变换:

ctx.save()
ctx.translate(cx, cy)           // 原点移到圆心
ctx.rotate(start + Math.PI / 8) // 旋转到扇形中间
ctx.fillText('一等奖', r - 14, 0)  // 沿半径方向写
ctx.restore()

原理:先把坐标系的原点移到圆心,再旋转坐标系,这样 X 轴方向就是半径方向了。


五、让它转起来:动画原理

5.1 动画本质

动画就是"连续播放静态画面"。在 Canvas 里,就是不断清空画布、重绘、清空、重绘……

5.2 减速效果

现实中的转盘是减速停止的,不是匀速。

我用了一个简单的物理模型:

let velocity = 0.4      // 初始速度
const decel = 0.98      // 衰减系数

setInterval(() => {
  this.wheelAngle += velocity
  velocity *= decel     // 每帧速度减少 2%
  this.drawWheel()      // 重绘
  
  if (velocity < 0.002) {
    clearInterval(timer)  // 停止
  }
}, 20)

效果:转盘开始转得很快,然后越来越慢,最后自然停止。

5.3 随机性

为了公平,每次转的速度应该不一样:

let velocity = 0.35 + Math.random() * 0.15  // 0.35 ~ 0.5

这样每次停止的位置都不同。


六、结果判定:数学题来了

6.1 问题描述

转盘停了,怎么知道中了什么奖?

已知:

  • 指针固定在正上方(Canvas 坐标 -π/2
  • 转盘转了 wheelAngle 弧度

求:指针指向哪个扇区?

6.2 我的推导

假设转盘顺时针转了 θ 弧度:

  1. 指针相对于转盘的位置:-π/2 - θ
  2. 除以每格角度:(-π/2 - θ) / (π/4)
  3. 这个数可能是负数或大于8,所以取模

6.3 坑:JavaScript 的负数取模

在 JavaScript 里,-3 % 8 = -3,不是 5

所以要修正:

let idx = ((raw % 8) + 8) % 8

这个技巧我之前在刷 LeetCode 时学过,没想到在这里用上了。

6.4 最终代码

determineResult(): void {
  const pointerAngle = -Math.PI / 2
  const segAngle = Math.PI / 4

  let raw = (pointerAngle - this.wheelAngle) / segAngle
  let idx = ((raw % 8) + 8) % 8
  idx = Math.floor(idx)

  this.result = this.items[idx]
}

七、美化:颜值即正义

7.1 配色

选了8个鲜艳的颜色:

const COLORS = [
  '#FF6B6B',  // 珊瑚红
  '#FFB347',  // 橙黄
  '#4ECDC4',  // 青绿
  '#45B7D1',  // 天蓝
  '#96CEB4',  // 薄荷绿
  '#FFEAA7',  // 柠檬黄
  '#DDA0DD',  // 粉紫
  '#98D8C8'   // 浅绿
]

7.2 中心圆

加个渐变的中心圆,看着更有质感:

const grad = ctx.createRadialGradient(155, 155, 0, 155, 155, 24)
grad.addColorStop(0, '#FFFFFF')
grad.addColorStop(1, '#F0F0F0')

ctx.arc(155, 155, 24, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()

7.3 指针

画个三角形指针,带阴影:

ctx.beginPath()
ctx.moveTo(px, py - 16)      // 顶点
ctx.lineTo(px - 12, py + 4)  // 左下
ctx.lineTo(px + 12, py + 4)  // 右下
ctx.closePath()

ctx.fillStyle = '#FF6B35'
ctx.shadowColor = 'rgba(0,0,0,0.2)'
ctx.shadowBlur = 6
ctx.fill()

7.4 外圈阴影

给转盘加个外圈阴影,增加立体感:

ctx.beginPath()
ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
ctx.shadowColor = 'rgba(0,0,0,0.15)'
ctx.shadowBlur = 12
ctx.fillStyle = '#FFFFFF'
ctx.fill()

八、功能完善:自定义选项

光有默认奖项太单调,加个编辑功能:

8.1 编辑弹层

if (this.showEditor) {
  Column() {
    Column() {
      Text('✏️ 自定义选项')
      Text('每行一项,最多8项')
      TextArea({ text: this.editText })
        .onChange((val) => { this.editText = val })
      Row() {
        Button('取消')
        Button('保存')
      }
    }
    .padding(20)
  }
  .backgroundColor('#80000000')
}

8.2 保存逻辑

saveItems(): void {
  const lines = this.editText
    .split('\n')
    .map(s => s.trim())
    .filter(s => s.length > 0)

  if (lines.length < 2) return  // 至少2项

  this.items = lines.slice(0, 8)  // 最多8项
  this.showEditor = false
  this.drawWheel()
}

九、历史记录:给用户反馈

抽奖结果应该能查,不然抽完就忘了。

9.1 数据存储

@State history: string[] = []

// 添加记录
this.history = [prize, ...this.history].slice(0, 50)

新记录插到数组头部,最多保留50条。

9.2 显示记录

ForEach(this.history.slice(0, 6), (item, idx) => {
  Row() {
    Text(`#${idx + 1}`)
    Text('🎯')
    Text(item)
  }
})

只显示最近6条,太多了也看不完。


十、踩坑记录

坑1:Canvas 绘制时机

问题:在 aboutToAppear 里画图报错。

原因:Canvas 还没初始化。

解决:用 onReady 回调。

坑2:定时器泄漏

问题:退出页面后转盘还在转。

原因:定时器没清理。

解决:在 aboutToDisappearclearInterval

坑3:文字截断

问题:奖项太长,超出扇形。

解决:手动截断:

const text = label.length > 6 ? label.slice(0, 5) + '…' : label

坑4:负数取模

问题:结果索引错误。

原因:JavaScript 的 % 可能返回负数。

解决((raw % 8) + 8) % 8

坑5:重复点击

问题:快速点多次抽奖,转盘乱转。

解决:用 isSpinning 状态判断:

if (this.isSpinning) return
this.isSpinning = true

十一、运行效果

在这里插入图片描述


十二、总结

技术收获

  1. Canvas API:和 Web 标准确实高度兼容,会前端的同学无缝迁移
  2. 动画原理:动画就是连续的静态画面,理解了这个就不再神秘
  3. 坐标变换:translate + rotate 大法好,解决文字旋转问题
  4. 数学基础:角度、弧度、取模,都是基本功

项目经验

  1. 从小处着手:先画圆,再画扇形,再加动画,一步步来
  2. 多测试边界:负数取模这种坑,不踩不知道
  3. 清理资源:定时器、监听器,该清理的一定要清理

代码量

最终统计:主文件 Index.ets 约 350 行。

对于一个完整的应用来说,这个量级刚刚好——既有足够的技术深度展示 Canvas 能力,又不会因为太复杂而劝退新手。


十三、后续改进

这个项目还有不少可以优化的地方:

  1. 音效:转盘转动时加点咔咔声,更有感觉
  2. 粒子效果:中奖时放个烟花动画
  3. 概率配置:让后台能设置每个奖项的概率
  4. 持久化:用 Preferences 保存历史记录

留着以后慢慢迭代吧,先把基础打牢。


写在最后

两天时间,从零撸了个抽奖转盘。虽然功能简单,但把 Canvas 的核心技能点都过了一遍。

最大的感受是:Canvas 其实不难,难的是耐心。各种坐标变换、角度计算,一个细节错就全错。但只要一步步来,多调试,总能搞定。

如果你也在学鸿蒙开发,建议找个小项目动手写一遍。看再多教程都不如亲自踩一遍坑。

有问题欢迎评论区交流!

Logo

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

更多推荐