前言

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

上周末我心血来潮,打开 DevEco Studio 6.1.1 Beta1,在 Pura X Max 模拟器上开始攒这个大转盘。用 Canvas 画扇区、写奖品文字、画指针,然后让整个转盘旋转起来,先快后慢,最后停在随机位置。下面就把这个过程从头到尾拆开给你看——不是教你怎么写代码,而是告诉你,这些动画效果背后藏着哪些有意思的知识点。最后附上完整代码,拷进模拟器就能玩起来。

一、画一个五彩斑斓的圆——扇形与颜色分配

大转盘的本质,是一个被等分成 N 份的圆,每份涂上不同颜色,再写上对应的奖品名称。在 Canvas 里画扇形,用的是 arc 方法,但要用 moveToclosePath 把弧和圆心连起来,形成一个闭合的扇形区域。

画一个扇形需要三个关键值:圆心坐标、半径、起始角度和终止角度。角度用弧度制,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 配合 moveToclosePath 画出闭合扇形,再用三角函数确定文字位置。
  • 极坐标与角度计算:通过扇区中心角和指针角度的对齐,算出精确的旋转量,保证抽奖结果可控。
  • 缓动函数(Ease-out):用三次方缓出公式让旋转先快后慢,模拟真实转盘的惯性减速效果。
  • 定时器驱动的动画循环:用 setInterval 每 16ms 更新角度并重绘,实现了流畅的 60fps 动画。
  • 动态数组与 UI 联动:通过 @State 绑定的奖品列表,添加和删除奖品后自动刷新 Canvas 和 UI 按钮。

往后如果想继续完善,可以给转盘加音效、加指针弹跳动画、或者用加速度传感器让用户“甩手机”来转。但眼下,这个版本已经足够撑起一场小型抽奖活动了——不用纸箱,不用纸条,只要一块屏幕,一个转盘,大家的眼睛都盯着那根红指针,心跳跟着它的速度一起变化。这就是动画的魅力。

Logo

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

更多推荐