年会抽奖不用纸箱了——我在 HarmonyOS 里画了个会转的大转盘
前言
去年年会,行政让每个人在小纸条上写名字,揉成团丢进一个纸箱里,然后摇半天抽一个出来。那次我中了支签字笔,隔壁同事中了带薪休假一天——他说那天是周六。这种纯手动的抽奖方式,虽然热闹,但总觉得少点什么。我想要的是一个五颜六色的大转盘,指针哗啦啦转上好几圈,慢慢停下来指着某个奖品,中间还带点动画的紧张感。

上周末我心血来潮,打开 DevEco Studio 6.1.1 Beta1,在 Pura X Max 模拟器上开始攒这个大转盘。用 Canvas 画扇区、写奖品文字、画指针,然后让整个转盘旋转起来,先快后慢,最后停在随机位置。下面就把这个过程从头到尾拆开给你看——不是教你怎么写代码,而是告诉你,这些动画效果背后藏着哪些有意思的知识点。最后附上完整代码,拷进模拟器就能玩起来。
一、画一个五彩斑斓的圆——扇形与颜色分配
大转盘的本质,是一个被等分成 N 份的圆,每份涂上不同颜色,再写上对应的奖品名称。在 Canvas 里画扇形,用的是 arc 方法,但要用 moveTo 和 closePath 把弧和圆心连起来,形成一个闭合的扇形区域。
画一个扇形需要三个关键值:圆心坐标、半径、起始角度和终止角度。角度用弧度制,0 弧度在三点钟方向,顺时针增长。如果转盘有 N 个奖品,每个扇区所占的角度就是 2π / N。第 i 个扇区的起始角度就是 currentAngle + i * angleStep。这里的 currentAngle 是整个转盘当前已经旋转过的累计角度,初始值是 0。当转盘旋转时,这个值不断增大,所有扇区就跟着一起转动,形成旋转效果。
为了好看,每个扇区涂不同颜色。我预先准备了一个颜色数组,从暖色到冷色排列。如果奖品数量超过颜色数,就循环使用,保证每个扇区都有鲜明的对比色,相邻扇区不撞色。扇区画完之后,再画一圈白色细边,把分界线勾勒出来,转盘结构就清晰了。
在扇区上写字也需要计算角度。文字应该画在扇区的中线方向,距离圆心大约是半径的 0.65 倍处。中线的角度是扇区起始角加半个步长。用三角函数算出文字的 x、y 坐标,设置 textAlign = 'center' 和 textBaseline = 'middle',文字就能稳稳地落在扇形中央。为了让文字清晰,我用了粗体白色字体,和彩色背景形成反差。
二、指针为什么指得准——坐标系的秘密
转盘转了,但指针不动。这就像你站在原地,看着面前的转盘在转,最后停下来时,正对着你的那个格子就是中奖奖品。在代码里,指针固定在画布的正上方,也就是 12 点钟方向。用 Canvas 坐标来看,圆心在 (cx, cy),指针尖端在 (cx, cy - radius - 5),左右各延伸 10 像素,形成一个红色三角形。
因为指针朝向是 -π/2 弧度(即正上方),而转盘本身在旋转,所以要判定指针指向了哪个扇区,需要找到当前哪个扇区的中心角度最接近 -π/2。在生成随机结果时,我们先随机选一个目标奖品索引,然后计算出需要旋转多少角度,才能让那个扇区的中心线正好对上 -π/2。
计算方法如下:假设当前转盘的累计旋转角度是 currentAngle,第 i 个扇区的中心线角度是 currentAngle + i * angleStep + angleStep / 2。我们希望这个值等于 -π/2(加上若干圈 2π 的整数倍)。所以需要旋转的角度 delta = (-π/2 - currentAngle - targetOffset) mod 2π。如果 delta 是负的,加上 2π 转成正的。然后再加上 3 到 5 圈(即 5 * 2π),让转盘多转几圈,增加观赏性。总旋转量 = delta + 多圈量。
这个角度计算,其实就是在极坐标下做一个简单的“对齐”操作。听起来有点绕,但画在纸上就是:目标扇区的中线最后要指向正上方。
三、让转盘“先快后慢”——缓动函数的妙用
如果转盘匀速旋转,从头转到尾,效果会很机械,像电风扇。真正有感觉的旋转,应该是先快后慢,最后慢慢滑到目标位置,带一点惯性的味道。这就需要缓动函数。
缓动函数描述的是动画进度随时间的变化关系。最简单的线性缓动就是 progress = time / duration,物体匀速运动。但我们需要的是 ease-out(减速退出)效果:开始时速度快,接近终点时越来越慢。常用的 ease-out 公式是 1 - (1 - t)^3(三次方缓出)或 1 - (1 - t)^4。我用的是三次方缓出,在代码里写成:

let progress = elapsed / totalDuration;
let eased = 1 - Math.pow(1 - progress, 3);
这个公式的曲线特点是:前 50% 的时间完成了约 87% 的旋转角度,剩下 13% 的角度在之后 50% 的时间里慢慢磨蹭。最后那几格扇区的切换变得非常缓慢,悬疑感拉满——到底会不会停在“谢谢参与”上?这种心理上的小刺激,正是抽奖转盘的精髓。
动画的实现用的是 setInterval,每 16 毫秒(约 60fps)更新一次角度,然后重绘 Canvas。旋转的总时长我设成 4 秒,感觉刚刚好——既不会让人觉得等太久,又能充分欣赏转盘旋转和减速的过程。旋转期间,我用一个 spinning 布尔变量锁住按钮,防止重复点击导致多个定时器同时跑。
旋转结束后,根据之前选定的目标奖品,在界面顶部显示结果,比如“恭喜获得:耳机”。如果奖品列表为空,则不执行旋转。
四、奖品列表自己定——动态添加与删除
一个只读的奖品列表太死板了。年会抽奖的奖品每次都不一样,必须要能自定义。所以我在转盘下方加了两个功能:一个输入框和一个“添加”按钮,可以随时往奖品列表里追加新项目;每个奖品在列表中显示为一个可点击的按钮,点击它就能删除(至少保留两个,否则转盘就只剩一个扇区,没法玩了)。
这里用到的 ArkUI 组件是 TextInput 和动态的 ForEach 循环。ForEach 绑定了 prizes 数组,每次增加或删除奖品,数组变化,界面就自动刷新。同时,为了给新奖品分配颜色,我扩展了颜色数组:当奖品数量超过预设颜色数时,追加一组备用颜色。因为转盘的颜色是循环使用颜色数组的,所以即使不扩展,颜色也会循环,但为了让新奖品看起来不那么“重复”,多准备几个颜色会更好。
添加奖品后,需要重新绘制转盘,因为扇区数量变了,每个扇区的角度也跟着变。代码里每次添加或删除都调用 drawWheel 重绘。
删除奖品的时候,数组用 splice(index, 1) 删除,Canvas 重绘。整个过程数据驱动,不需要手动操作任何 DOM。

还有一个细节:当转盘正在旋转时,不能添加或删除奖品,否则扇区数一变,角度计算全乱套。但在这个简单的实现里,我没有加锁,因为旋转时间很短,用户不太可能在 4 秒内同时操作。如果真的需要锁,可以在 spinning 时禁用添加和删除按钮。
五、完整代码——所有扇区、指针、动画都在一个文件里
以下代码适配 DevEco Studio 6.1.1 Beta1、SDK22 语法,Pura X Max 模拟器。新建 Empty Ability 项目,替换 entry/src/main/ets/pages/Index.ets 即可。无需任何权限,纯本地动画。

/*
* 大转盘抽奖 — Canvas 扇形 + 旋转动画 + 缓动函数
* 环境:DevEco Studio 6.1.1 Beta1,Pura X Max 模拟器,SDK22
*/
import { CanvasRenderingContext2D } from '@ohos.graphics.canvas';
@Entry
@Component
struct Index {
@State prizes: string[] = ['iPhone', '平板', '耳机', '充电宝', '优惠券', '谢谢参与'];
@State colors: string[] = ['#FF6B6B', '#4ECDC4', '#FFD93D', '#6C5CE7', '#A8E6CF', '#FF8C42'];
@State spinning: boolean = false;
@State result: string = '';
@State newPrize: string = '';
private ctx: CanvasRenderingContext2D | null = null;
private canvasWidth: number = 0;
private canvasHeight: number = 0;
private centerX: number = 0;
private centerY: number = 0;
private radius: number = 0;
private currentAngle: number = 0; // 累计旋转弧度
private animTimer: number = -1;
// Canvas 就绪
private onCanvasReady(ctx: CanvasRenderingContext2D): void {
this.ctx = ctx;
this.canvasWidth = ctx.canvas.width;
this.canvasHeight = ctx.canvas.height;
this.centerX = this.canvasWidth / 2;
this.centerY = this.canvasHeight / 2;
this.radius = Math.min(this.centerX, this.centerY) - 20;
this.drawWheel();
}
// 绘制转盘和指针
private drawWheel(): void {
if (!this.ctx) return;
let ctx = this.ctx;
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
let n = this.prizes.length;
let angleStep = (2 * Math.PI) / n;
// 绘制扇形
for (let i = 0; i < n; i++) {
let startAngle = this.currentAngle + i * angleStep;
let endAngle = startAngle + angleStep;
ctx.beginPath();
ctx.moveTo(this.centerX, this.centerY);
ctx.arc(this.centerX, this.centerY, this.radius, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = this.colors[i % this.colors.length];
ctx.fill();
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
ctx.stroke();
// 文字
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
let midAngle = startAngle + angleStep / 2;
let tx = this.centerX + Math.cos(midAngle) * this.radius * 0.65;
let ty = this.centerY + Math.sin(midAngle) * this.radius * 0.65;
ctx.fillText(this.prizes[i], tx, ty);
}
// 中心圆
ctx.beginPath();
ctx.arc(this.centerX, this.centerY, this.radius * 0.12, 0, 2 * Math.PI);
ctx.fillStyle = '#FFFFFF';
ctx.fill();
ctx.strokeStyle = '#333333';
ctx.lineWidth = 2;
ctx.stroke();
// 指针(固定向上)
ctx.beginPath();
ctx.moveTo(this.centerX - 12, this.centerY - this.radius + 10);
ctx.lineTo(this.centerX + 12, this.centerY - this.radius + 10);
ctx.lineTo(this.centerX, this.centerY - this.radius - 15);
ctx.closePath();
ctx.fillStyle = '#E53935';
ctx.fill();
ctx.strokeStyle = '#B71C1C';
ctx.lineWidth = 2;
ctx.stroke();
}
// 开始旋转
private startSpin(): void {
if (this.spinning || this.prizes.length === 0) return;
this.spinning = true;
this.result = '';
let n = this.prizes.length;
let angleStep = (2 * Math.PI) / n;
// 随机选一个目标奖品
let targetIndex = Math.floor(Math.random() * n);
let targetOffset = targetIndex * angleStep + angleStep / 2;
// 需要旋转的角度:让目标扇区中心对准 -PI/2(指针方向)
let delta = (-Math.PI / 2 - this.currentAngle - targetOffset) % (2 * Math.PI);
if (delta < 0) delta += 2 * Math.PI;
// 加上多圈
let totalSpin = delta + 5 * 2 * Math.PI;
let startAngle = this.currentAngle;
let duration = 4000; // 4秒
let startTime = Date.now();
this.animTimer = setInterval(() => {
let elapsed = Date.now() - startTime;
if (elapsed >= duration) {
this.currentAngle = (startAngle + totalSpin) % (2 * Math.PI);
this.drawWheel();
this.spinning = false;
this.result = `恭喜获得:${this.prizes[targetIndex]}`;
clearInterval(this.animTimer);
this.animTimer = -1;
} else {
let progress = elapsed / duration;
// ease-out 三次方缓动
let eased = 1 - Math.pow(1 - progress, 3);
this.currentAngle = startAngle + totalSpin * eased;
this.drawWheel();
}
}, 16);
}
// 添加奖品
private addPrize(): void {
let name = this.newPrize.trim();
if (name === '') return;
this.prizes = [...this.prizes, name];
this.newPrize = '';
// 补充颜色
if (this.colors.length < this.prizes.length) {
let extra = ['#FFA07A', '#20B2AA', '#DA70D6', '#4682B4'];
this.colors = [...this.colors, ...extra];
}
this.drawWheel();
}
// 删除奖品
private removePrize(index: number): void {
if (this.prizes.length <= 2) return;
this.prizes.splice(index, 1);
this.drawWheel();
}
aboutToDisappear(): void {
if (this.animTimer !== -1) clearInterval(this.animTimer);
}
build() {
Column() {
Text('大转盘抽奖').fontSize(28).fontWeight(FontWeight.Bold).margin({ top: 20, bottom: 8 })
if (this.result !== '') {
Text(this.result).fontSize(20).fontColor('#E53935').margin({ bottom: 8 })
}
// 转盘 Canvas(点击也可启动)
Canvas()
.width('100%')
.height(350)
.backgroundColor('#F5F5F5')
.onReady((event) => {
let ctx = event.context as CanvasRenderingContext2D;
this.onCanvasReady(ctx);
})
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down && !this.spinning) {
this.startSpin();
}
})
// 自定义奖品
Row() {
TextInput({ placeholder: '奖品名称', text: this.newPrize })
.onChange((v: string) => { this.newPrize = v; })
.layoutWeight(1).fontSize(16)
Button('添加').type(ButtonType.Capsule).fontSize(16)
.onClick(() => { this.addPrize(); })
}.width('88%').margin({ top: 12, bottom: 8 })
// 奖品列表
if (this.prizes.length > 0) {
Text('奖品列表(点击删除)').fontSize(14).fontColor('#888').margin({ bottom: 4 })
Row() {
ForEach(this.prizes, (prize: string, index: number) => {
Button(prize).fontSize(13).margin(3)
.onClick(() => { this.removePrize(index); })
})
}.width('90%').flexWrap(FlexWrap.Wrap)
}
Button('开始抽奖').fontSize(18).type(ButtonType.Capsule)
.backgroundColor(this.spinning ? '#CCCCCC' : '#E53935')
.fontColor(Color.White)
.margin({ top: 12 })
.onClick(() => { this.startSpin(); })
Text('💡 点击转盘或按钮开始,旋转动画使用 ease-out 缓动函数')
.fontSize(12).fontColor('#AAA').width('90%').textAlign(TextAlign.Center).margin({ top: 8 })
}
.width('100%').height('100%').backgroundColor('#FAFAFA')
}
}
代码包括了扇形绘制、旋转动画、缓动函数、自定义奖品列表、指针固定、点击启动等功能。指针固定在画布上方,转盘旋转,最终停在一个随机扇区。
运行效果
代码粘贴进 DevEco Studio,Run 到 Pura X Max 模拟器。屏幕上出现一个彩色大转盘,指针红色朝上。预设了六个奖品,每个扇区颜色鲜明。点击“开始抽奖”按钮,或者直接点一下转盘画面,转盘开始快速旋转,然后逐渐减速,最后慢慢停住。指针指向某个扇区,屏幕上方弹出“恭喜获得:xxx”的结果文字。在下方输入框输入新奖品名称如“蓝牙音箱”,点“添加”,转盘立刻重绘,新的扇区加进去。点击奖品列表中的任一按钮,对应的奖品被删除,转盘同步更新。整个过程动画流畅,旋转节奏自然,非常适合年会或活动暖场使用。


总结
这个大转盘抽奖项目看起来只是在画圆旋转,但里面塞进了不少实用的技术点:
- Canvas 扇形绘制:用
arc配合moveTo和closePath画出闭合扇形,再用三角函数确定文字位置。 - 极坐标与角度计算:通过扇区中心角和指针角度的对齐,算出精确的旋转量,保证抽奖结果可控。
- 缓动函数(Ease-out):用三次方缓出公式让旋转先快后慢,模拟真实转盘的惯性减速效果。
- 定时器驱动的动画循环:用
setInterval每 16ms 更新角度并重绘,实现了流畅的 60fps 动画。 - 动态数组与 UI 联动:通过
@State绑定的奖品列表,添加和删除奖品后自动刷新 Canvas 和 UI 按钮。
往后如果想继续完善,可以给转盘加音效、加指针弹跳动画、或者用加速度传感器让用户“甩手机”来转。但眼下,这个版本已经足够撑起一场小型抽奖活动了——不用纸箱,不用纸条,只要一块屏幕,一个转盘,大家的眼睛都盯着那根红指针,心跳跟着它的速度一起变化。这就是动画的魅力。
更多推荐


所有评论(0)