码农周末:用鸿蒙 Canvas 撸了个抽奖转盘,顺便把动画原理搞明白了
码农周末:用鸿蒙 Canvas 撸了个抽奖转盘,顺便把动画原理搞明白了
周末闲着没事,寻思着学点新技术。之前一直听说鸿蒙的 Canvas API 和 Web 标准高度兼容,就想着搞个实战项目验证一下。
最后选了抽奖转盘——功能不复杂,但该有的技术点都有:Canvas绑制、动画、状态管理、用户交互。而且这玩意儿做出来还挺实用的,年会、活动都能用。
一、为什么选 Canvas?
其实鸿蒙提供了很多实现转盘的方案:
- Image + Rotation:用图片做转盘,旋转动画
- 自定义组件:用 ArkUI 的 Shape 组件
- Canvas:手动绑制
为什么选 Canvas?几个原因:
- 灵活:想画啥画啥,不受限制
- 轻量:不用准备一堆图片资源
- 学习价值: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 我的推导
假设转盘顺时针转了 θ 弧度:
- 指针相对于转盘的位置:
-π/2 - θ - 除以每格角度:
(-π/2 - θ) / (π/4) - 这个数可能是负数或大于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:定时器泄漏
问题:退出页面后转盘还在转。
原因:定时器没清理。
解决:在 aboutToDisappear 里 clearInterval。
坑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
十一、运行效果

十二、总结
技术收获
- Canvas API:和 Web 标准确实高度兼容,会前端的同学无缝迁移
- 动画原理:动画就是连续的静态画面,理解了这个就不再神秘
- 坐标变换:translate + rotate 大法好,解决文字旋转问题
- 数学基础:角度、弧度、取模,都是基本功
项目经验
- 从小处着手:先画圆,再画扇形,再加动画,一步步来
- 多测试边界:负数取模这种坑,不踩不知道
- 清理资源:定时器、监听器,该清理的一定要清理
代码量
最终统计:主文件 Index.ets 约 350 行。
对于一个完整的应用来说,这个量级刚刚好——既有足够的技术深度展示 Canvas 能力,又不会因为太复杂而劝退新手。
十三、后续改进
这个项目还有不少可以优化的地方:
- 音效:转盘转动时加点咔咔声,更有感觉
- 粒子效果:中奖时放个烟花动画
- 概率配置:让后台能设置每个奖项的概率
- 持久化:用 Preferences 保存历史记录
留着以后慢慢迭代吧,先把基础打牢。
写在最后
两天时间,从零撸了个抽奖转盘。虽然功能简单,但把 Canvas 的核心技能点都过了一遍。
最大的感受是:Canvas 其实不难,难的是耐心。各种坐标变换、角度计算,一个细节错就全错。但只要一步步来,多调试,总能搞定。
如果你也在学鸿蒙开发,建议找个小项目动手写一遍。看再多教程都不如亲自踩一遍坑。
有问题欢迎评论区交流!
更多推荐


所有评论(0)