鸿蒙开发实战——抽奖转盘

1、前 言

有朋友留言:能出一个抽奖转盘的教程吗 网上好像没有鸿蒙做圆形扇形的源码,最好做个可以选择自增的 类似于根据数组元素自增扇形切割圆形面积的。

这里咱们就讨论下这个实现方案。先看效果(文末有源代码):

中间有一个大圆盘,圆盘顶部有一个指针,圆盘正中央有一个圆形的“开始/结束”控制按钮。

2、需求分析

通过这位朋友的留言,我们可以分析出两个核心能力:1、实现一个抽奖圆盘;2、元素个数可以支持动态添加。

❓ 如何实现一个抽奖圆盘?

考虑到圆盘内容是动态的,因此我们考虑使用Canvas来进行动态绘制【之前我们有讨论过Canvas的使用,详见 👉🏻 鸿蒙UI系统组件15——画布(Canvas)

一般情况下,我们圆盘是允许“控制结果”的,即,我们可以通过变量控制让圆盘指定落在哪个扇区范围。

3、技术实现

3.1、画一个圆盘

圆盘正上方上有一个三角形指针,指针下方就是圆盘本身,我们使用Canvas来绘制一个圆盘,核心代码如下(其中ItemList是一个动态数组,根据数组的个数切割圆盘):

// ...itemList: Array<DrawItem> =  [{ color: 'red' }, { color: 'green' }, { color: 'blue' }, { color: '#F5DC62' }, { color: 'black' }];// ... drawPanel() {  this.context.clearRect(0, 0, this.context.width, this.context.height);  if (this.itemList.length === 0) {    return;  }  // 中心点和半径尺寸信息  const x = this.context.width / 2;  const y = this.context.height / 2 + this.canvasMarginTop;  const r = this.context.width / 2;  const itemAngle = 360 / this.itemList.length; // 平均分配角度  // 绘制三角形指针  this.context.fillStyle = 'red';  this.context.beginPath();  this.context.moveTo(x, y - r);  this.context.lineTo(x + this.canvasTriangleSize, y - r - this.canvasTriangleSize);  this.context.lineTo(x - this.canvasTriangleSize, y - r - this.canvasTriangleSize);  this.context.fill();  this.context.closePath();  for (let i = 0; i < this.itemList.length; i++) {    const element = this.itemList[i];    this.context.fillStyle = element.color;    this.context.beginPath();    this.context.moveTo(x, y);    this.context.arc(x, y, r, this.angle(i * itemAngle), this.angle((i + 1) * itemAngle));    this.context.fill();    this.context.closePath();  }}

3.2、圆盘中央添加一个“启动/停止”按钮

我们使用层叠布局(Stack),在圆盘顶部添加一个圆形按钮,代码如下:

build() {  Column() {    Row() {      Stack({ alignContent: Alignment.Center }) {        //在canvas中调用CanvasRenderingContext2D对象。        Canvas(this.context)          .width('100%')          .height('100%')          .onReady(() => this.drawPanel())        Flex({ alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }) {          Button(this.buttonText)            .width(this.centerButtonSize)            .height(this.centerButtonSize)            .type(ButtonType.Circle)            .margin({ top: this.centerButtonSize / 2 + this.canvasMarginTop })            .onClick(() => this.onButtonClick())        }        .width('100%')        .height('100%')      }    }    .height('70%')    Row() {      Button('重置').width('100%').backgroundColor(Color.Orange).onClick(() => this.reset());    }.padding({ left: 20, right: 20 })    .width('100%')  }}

3.3、让圆盘转起来

我们在鸿蒙UI开发——自定义UI绘制帧率文章中讨论过,我们可以使用自定义绘制帧率来控制我们的Canvas动态绘制。

如果想让我们的圆盘动起来,则需要用到此能力,实现自定义渲染(下文代码中的frameCall方法以及21行代码)。

除了自定义渲染外,我们还需要让圆盘转动,这个相对比较简单,我们只需要让圆盘的初始角度根据时间发生累加即可(下文代码中的8行)。

代码如下(请注意31行代码):​​​​​​​

private frameCall = () => {  if (this.canvasStatus !== CanvasStatus.stopping) {    this.currentSpeed = this.speed;  } else {    this.currentSpeed -= this.slowDownSpeed;  }  // draw call  this.startAngle = (this.startAngle + this.currentSpeed) % 360;  this.drawPanel();  if (this.currentSpeed <= 0) {    this.releaseDisplaySync();    this.canvasStatus = CanvasStatus.stopped;  }};startDisplaySync() {  this.releaseDisplaySync(); // 取消上次的记录,如果有的话  this.backDisplaySyncFast = displaySync.create(); // 创建DisplaySync实例  this.backDisplaySyncFast.setExpectedFrameRateRange(this.range); // 设置帧率  this.backDisplaySyncFast.on("frame", this.frameCall); // 订阅frame事件和注册订阅函数  this.backDisplaySyncFast.start(); // DisplaySync使能开启  this.buttonText = '停止';  this.canvasStatus = CanvasStatus.running;}onButtonClick() {  // ...    if (this.backDisplaySyncFast == undefined) {      this.startDisplaySync();    }  // ...}

3.4、让圆盘停在指定扇形范围

我们在实现时,减速算法是固定的,那么在用户点击停止后,减速过程经过的度数我们也是知道的,因此,可以倒推在停止前,我们应该将圆盘从什么角度开始减速。代码如下(靠expectedIndex变量控制):​​​​​​​

// 计算期望停止的扇区,此时的startAngle应该是多少。let len = (this.speed - this.slowDownSpeed + 0) * (this.speed / this.slowDownSpeed) / 2;len %= 360;const itemAngle = 360 / this.itemList.length; // 平均分配角度const min = (this.itemList.length - 1 - this.expectedIndex) * itemAngle;const angle = min + Math.random() * itemAngle; // 在指定扇形区域生成一个随机数if (angle < len) {  this.startAngle = 360 + angle - len;} else {  this.startAngle = angle - len;}

4、源代码

源代码如下,可直接粘贴运行。​​​​​​​

import { displaySync } from '@kit.ArkGraphics2D';interface DrawItem {  color: string;}enum CanvasStatus {  normal = 0, // 画布初始状态  running = 1, // 画布正在轮转(点击了开始抽奖)  stopping = 2, // 画布正在停止中(点击了停止抽奖)  stopped = 3, // 已经停止(惯性停止完毕)}@Entry@Componentstruct Index {  //用来配置CanvasRenderingContext2D对象的参数,包括是否开启抗锯齿,true表明开启抗锯齿。  private settings: RenderingContextSettings = new RenderingContextSettings(true)  //用来创建CanvasRenderingContext2D对象,通过在canvas中调用CanvasRenderingContext2D对象来绘制。  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)  private backDisplaySyncFast: displaySync.DisplaySync | undefined = undefined;  // 以下是一些配置信息  private range: ExpectedFrameRateRange = { expected: 90, min: 0, max: 120 };  private canvasMarginTop: number = 30;  private centerButtonSize = 80;  private canvasTriangleSize = 20;  private startAngle: number = 0;  private speed: number = 20; // 每帧转多少度  private currentSpeed: number = this.speed;  private canvasStatus: CanvasStatus = CanvasStatus.normal;  private expectedIndex = 2; // 期望选中的内容(从0开始,这里的2表示永远选中蓝色区域)  private slowDownSpeed = 0.2;  @State buttonText: string = '开始';  private frameCall = () => {    if (this.canvasStatus !== CanvasStatus.stopping) {      this.currentSpeed = this.speed;    } else {      this.currentSpeed -= this.slowDownSpeed;    }    // draw call    this.startAngle = (this.startAngle + this.currentSpeed) % 360;    this.drawPanel();    if (this.currentSpeed <= 0) {      this.releaseDisplaySync();      this.canvasStatus = CanvasStatus.stopped;    }  };  itemList: Array<DrawItem> =    [{ color: 'red' }, { color: 'green' }, { color: 'blue' }, { color: '#F5DC62' }, { color: 'black' }];  startDisplaySync() {    this.releaseDisplaySync(); // 取消上次的记录,如果有的话    this.backDisplaySyncFast = displaySync.create(); // 创建DisplaySync实例    this.backDisplaySyncFast.setExpectedFrameRateRange(this.range); // 设置帧率    this.backDisplaySyncFast.on("frame", this.frameCall); // 订阅frame事件和注册订阅函数    this.backDisplaySyncFast.start(); // DisplaySync使能开启    this.buttonText = '停止';    this.canvasStatus = CanvasStatus.running;  }  releaseDisplaySync() {    if (this.backDisplaySyncFast) {      this.backDisplaySyncFast.stop(); // DisplaySync失能关闭      this.backDisplaySyncFast = undefined; // 实例置空    }    this.buttonText = '开始';    this.canvasStatus = CanvasStatus.normal;  }  aboutToDisappear() {    this.releaseDisplaySync();  }  // 将垂直上方视为0度  angle(n: number) {    return Math.PI / 180 * (this.startAngle + n - 90);  }  drawPanel() {    this.context.clearRect(0, 0, this.context.width, this.context.height);    if (this.itemList.length === 0) {      return;    }    // 中心点和半径尺寸信息    const x = this.context.width / 2;    const y = this.context.height / 2 + this.canvasMarginTop;    const r = this.context.width / 2;    const itemAngle = 360 / this.itemList.length; // 平均分配角度    // 绘制三角形指针    this.context.fillStyle = 'red';    this.context.beginPath();    this.context.moveTo(x, y - r);    this.context.lineTo(x + this.canvasTriangleSize, y - r - this.canvasTriangleSize);    this.context.lineTo(x - this.canvasTriangleSize, y - r - this.canvasTriangleSize);    this.context.fill();    this.context.closePath();    for (let i = 0; i < this.itemList.length; i++) {      const element = this.itemList[i];      this.context.fillStyle = element.color;      this.context.beginPath();      this.context.moveTo(x, y);      this.context.arc(x, y, r, this.angle(i * itemAngle), this.angle((i + 1) * itemAngle));      this.context.fill();      this.context.closePath();    }  }  build() {    Column() {      Row() {        Stack({ alignContent: Alignment.Center }) {          //在canvas中调用CanvasRenderingContext2D对象。          Canvas(this.context)            .width('100%')            .height('100%')            .onReady(() => this.drawPanel())          Flex({ alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }) {            Button(this.buttonText)              .width(this.centerButtonSize)              .height(this.centerButtonSize)              .type(ButtonType.Circle)              .margin({ top: this.centerButtonSize / 2 + this.canvasMarginTop })              .onClick(() => this.onButtonClick())          }          .width('100%')          .height('100%')        }      }      .height('70%')      Row() {        Button('重置').width('100%').backgroundColor(Color.Orange).onClick(() => this.reset());      }.padding({ left: 20, right: 20 })      .width('100%')    }  }  onButtonClick() {    if (this.canvasStatus === CanvasStatus.running) {      this.canvasStatus = CanvasStatus.stopping;      // 计算期望停止的扇区,此时的startAngle应该是多少。      let len = (this.speed - this.slowDownSpeed + 0) * (this.speed / this.slowDownSpeed) / 2;      len %= 360;      const itemAngle = 360 / this.itemList.length; // 平均分配角度      const min = (this.itemList.length - 1 - this.expectedIndex) * itemAngle;      const angle = min + Math.random() * itemAngle; // 在指定扇形区域生成一个随机数      if (angle < len) {        this.startAngle = 360 + angle - len;      } else {        this.startAngle = angle - len;      }    } else {      if (this.backDisplaySyncFast == undefined) {        this.startDisplaySync();      }    }  }  reset() {    this.releaseDisplaySync();    this.startAngle = 0;    this.drawPanel();  }}

5、尾巴

当前案例仅实现了非常核心的内容,不同的业务场景可能转盘的样式不同,但实现方式是一致的。未来我们还可以有以下几方面的改进:

1. 组件抽象

将实现包装成一个独立组件,外部业务直接使用。

2. 圆盘上绘制更多内容

可以支持更多内容绘制,例如:文字、图片等

3. 动画效果

可以进一步优化轮盘转动效果与停止的阻尼效果。

代码仓库

https://gitee.com/lantingshuxu/harmony-class-room-demos/tree/feat%2Fwheel/
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值