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
@Component
struct 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/