码农周末:用鸿蒙 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(()=>{constctx=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个扇形
constcolors=['#FF6B6B','#FFB347','#4ECDC4','#45B7D1','#96CEB4','#FFEAA7','#DDA0DD','#98D8C8']for(leti=0;i<8;i++){conststart=i*Math.PI/4constend=start+Math.PI/4ctx.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 减速效果
现实中的转盘是减速停止的,不是匀速。
我用了一个简单的物理模型:
letvelocity=0.4// 初始速度constdecel=0.98// 衰减系数setInterval(()=>{this.wheelAngle+=velocity velocity*=decel// 每帧速度减少 2%this.drawWheel()// 重绘if(velocity<0.002){clearInterval(timer)// 停止}},20)效果:转盘开始转得很快,然后越来越慢,最后自然停止。
5.3 随机性
为了公平,每次转的速度应该不一样:
letvelocity=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!
所以要修正:
letidx=((raw%8)+8)%8这个技巧我之前在刷 LeetCode 时学过,没想到在这里用上了。
6.4 最终代码
determineResult():void{constpointerAngle=-Math.PI/2constsegAngle=Math.PI/4letraw=(pointerAngle-this.wheelAngle)/segAngleletidx=((raw%8)+8)%8idx=Math.floor(idx)this.result=this.items[idx]}七、美化:颜值即正义
7.1 配色
选了8个鲜艳的颜色:
constCOLORS=['#FF6B6B',// 珊瑚红'#FFB347',// 橙黄'#4ECDC4',// 青绿'#45B7D1',// 天蓝'#96CEB4',// 薄荷绿'#FFEAA7',// 柠檬黄'#DDA0DD',// 粉紫'#98D8C8'// 浅绿]7.2 中心圆
加个渐变的中心圆,看着更有质感:
constgrad=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=6ctx.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=12ctx.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{constlines=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=falsethis.drawWheel()}九、历史记录:给用户反馈
抽奖结果应该能查,不然抽完就忘了。
9.1 数据存储
@Statehistory: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:文字截断
问题:奖项太长,超出扇形。
解决:手动截断:
consttext=label.length>6?label.slice(0,5)+'…':label坑4:负数取模
问题:结果索引错误。
原因:JavaScript 的%可能返回负数。
解决:((raw % 8) + 8) % 8。
坑5:重复点击
问题:快速点多次抽奖,转盘乱转。
解决:用isSpinning状态判断:
if(this.isSpinning)returnthis.isSpinning=true十一、运行效果
十二、总结
技术收获
- Canvas API:和 Web 标准确实高度兼容,会前端的同学无缝迁移
- 动画原理:动画就是连续的静态画面,理解了这个就不再神秘
- 坐标变换:translate + rotate 大法好,解决文字旋转问题
- 数学基础:角度、弧度、取模,都是基本功
项目经验
- 从小处着手:先画圆,再画扇形,再加动画,一步步来
- 多测试边界:负数取模这种坑,不踩不知道
- 清理资源:定时器、监听器,该清理的一定要清理
代码量
最终统计:主文件Index.ets约 350 行。
对于一个完整的应用来说,这个量级刚刚好——既有足够的技术深度展示 Canvas 能力,又不会因为太复杂而劝退新手。
十三、后续改进
这个项目还有不少可以优化的地方:
- 音效:转盘转动时加点咔咔声,更有感觉
- 粒子效果:中奖时放个烟花动画
- 概率配置:让后台能设置每个奖项的概率
- 持久化:用 Preferences 保存历史记录
留着以后慢慢迭代吧,先把基础打牢。
写在最后
两天时间,从零撸了个抽奖转盘。虽然功能简单,但把 Canvas 的核心技能点都过了一遍。
最大的感受是:Canvas 其实不难,难的是耐心。各种坐标变换、角度计算,一个细节错就全错。但只要一步步来,多调试,总能搞定。
如果你也在学鸿蒙开发,建议找个小项目动手写一遍。看再多教程都不如亲自踩一遍坑。
有问题欢迎评论区交流!