HarmonyOS NEXT Canvas 实战:从零开发抽奖转盘应用
·
HarmonyOS NEXT Canvas 实战:从零开发抽奖转盘应用
前言
Canvas 绑制是移动开发中一项重要的技能,无论是游戏开发、数据可视化还是创意交互,都离不开它。本文将带你从零开始,使用 HarmonyOS NEXT 的 Canvas API 开发一个完整的抽奖转盘应用。
项目信息:
- 包名:
com.example.myapplication - API版本:HarmonyOS NEXT (API 23+)
- 核心技术:Canvas 2D、动画系统、状态管理
一、需求分析
1.1 功能需求
| 功能模块 | 描述 |
|---|---|
| 转盘显示 | 8格彩色扇形,Canvas绑制 |
| 抽奖动画 | 减速旋转,自然停止 |
| 结果判定 | 精确计算指针指向的扇区 |
| 自定义奖项 | 用户可编辑奖项内容 |
| 历史记录 | 保存抽奖历史,可清空 |
1.2 技术难点
- Canvas 扇形绘制:如何正确绘制等分扇形并填充颜色
- 文字旋转:如何让每个扇形的文字沿半径方向显示
- 动画系统:如何实现物理减速效果
- 结果算法:如何精确判断指针指向的扇区
二、Canvas 基础
2.1 初始化 Canvas
@Entry
@Component
struct LuckySpinWheel {
private canvasCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D()
private readonly CANVAS_SIZE: number = 310
build() {
Canvas(this.canvasCtx)
.width(this.CANVAS_SIZE)
.height(this.CANVAS_SIZE)
.onReady(() => {
this.drawWheel()
})
}
}
关键点:
CanvasRenderingContext2D:2D绑制上下文,API与Web标准一致onReady:Canvas初始化完成后的回调,此时可以开始绑制
2.2 Canvas 坐标系
Canvas 使用左上角为原点的坐标系:
(0,0) ────────────────► X
│
│
│
▼
Y
对于310x310的画布,圆心坐标为 (155, 155)。
2.3 绘制基本图形
绘制圆形:
ctx.beginPath()
ctx.arc(155, 155, 138, 0, Math.PI * 2)
ctx.fillStyle = '#FF6B6B'
ctx.fill()
绘制扇形:
ctx.beginPath()
ctx.moveTo(cx, cy) // 移动到圆心
ctx.arc(cx, cy, r, start, end) // 绘制弧线
ctx.closePath() // 闭合路径
ctx.fillStyle = color
ctx.fill()
三、转盘绘制实现
3.1 定义配置常量
// 配色方案(8种鲜艳颜色)
const SEGMENT_COLORS: string[] = [
'#FF6B6B', '#FFB347', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'
]
// 文字颜色(浅色背景配深字,深色背景配浅字)
const SEGMENT_TEXT_COLORS: string[] = [
'#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF',
'#333333', '#333333', '#333333', '#333333'
]
// 默认奖项
const DEFAULT_ITEMS: string[] = [
'一等奖', '二等奖', '三等奖', '幸运奖',
'再接再厉', '好运', '鼓励奖', '参与奖'
]
3.2 绘制转盘主体
drawWheel(): void {
const ctx = this.canvasCtx
const cx = 155 // 圆心X
const cy = 155 // 圆心Y
const r = 138 // 半径
const n = 8 // 扇区数量
const segAngle = Math.PI * 2 / n // 每格角度
// 清空画布
ctx.clearRect(0, 0, 310, 310)
// 绘制外圈阴影
ctx.beginPath()
ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
ctx.shadowColor = 'rgba(0,0,0,0.15)'
ctx.shadowBlur = 12
ctx.fillStyle = '#FFFFFF'
ctx.fill()
ctx.shadowBlur = 0
// 绘制每个扇区
for (let i = 0; i < n; i++) {
const start = this.wheelAngle + i * segAngle
const end = start + segAngle
// 填充扇形
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.arc(cx, cy, r, start, end)
ctx.closePath()
ctx.fillStyle = SEGMENT_COLORS[i]
ctx.fill()
ctx.strokeStyle = '#FFFFFF'
ctx.lineWidth = 2
ctx.stroke()
}
// ... 继续绘制文字和中心圆
}
3.3 绘制旋转文字
文字需要沿半径方向显示,这需要坐标变换:
for (let i = 0; i < n; i++) {
const start = this.wheelAngle + i * segAngle
// 文字绘制
ctx.save()
ctx.translate(cx, cy) // 原点移到圆心
ctx.rotate(start + segAngle / 2) // 旋转到扇区中间
ctx.textAlign = 'right' // 右对齐
ctx.textBaseline = 'middle' // 垂直居中
ctx.font = '15px HarmonyOS Sans'
ctx.fillStyle = SEGMENT_TEXT_COLORS[i]
// 截断过长文字
const label = this.items[i] || ''
const displayText = label.length > 6 ? label.slice(0, 5) + '…' : label
ctx.fillText(displayText, r - 14, 0)
ctx.restore()
}
变换过程:
translate(cx, cy):将坐标系原点移到圆心rotate(angle):旋转坐标系到扇区中间- 绘制文字时,X轴方向就是半径方向
3.4 绘制中心圆
使用径向渐变增加立体感:
// 创建径向渐变
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, 24)
grad.addColorStop(0, '#FFFFFF')
grad.addColorStop(1, '#F0F0F0')
ctx.beginPath()
ctx.arc(cx, cy, 24, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()
ctx.strokeStyle = '#E0E0E0'
ctx.lineWidth = 2
ctx.stroke()
// 中心文字
ctx.fillStyle = '#FF6B35'
ctx.font = 'bold 14px HarmonyOS Sans'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('LUCKY', cx, cy - 1)
3.5 绘制指针
指针固定在转盘上方,指向下方:
const px = cx
const py = cy - r - 8 // 转盘上方
ctx.save()
ctx.shadowColor = 'rgba(0,0,0,0.2)'
ctx.shadowBlur = 6
// 三角形指针
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.fill()
ctx.strokeStyle = '#E05A2A'
ctx.lineWidth = 1.5
ctx.stroke()
ctx.restore()
四、动画系统实现
4.1 状态定义
@State wheelAngle: number = 0 // 当前旋转角度(弧度)
@State isSpinning: boolean = false // 是否正在旋转
private spinTimerId: number = -1 // 定时器ID
4.2 减速动画
采用物理减速模型:
startSpin(): void {
if (this.isSpinning) return
this.isSpinning = true
this.showResult = false
// 初始角速度(带随机)
let velocity = 0.35 + Math.random() * 0.15
// 衰减系数
const decel = 0.978
// 最小速度阈值
const minVelocity = 0.002
// 定时器驱动动画
this.spinTimerId = setInterval(() => {
// 更新角度
this.wheelAngle = (this.wheelAngle + velocity) % (Math.PI * 2)
// 速度衰减
velocity *= decel
// 重绘
this.drawWheel()
// 判断停止
if (velocity < minVelocity) {
this.stopSpin()
this.determineResult()
}
}, 20) // 20ms一帧,约50FPS
}
4.3 参数调优
| 参数 | 值 | 作用 |
|---|---|---|
| 初始速度 | 0.35~0.5 | 随机化确保每次结果不同 |
| 衰减系数 | 0.978 | 控制减速快慢 |
| 最小速度 | 0.002 | 停止阈值 |
| 帧间隔 | 20ms | 平衡流畅度与性能 |
4.4 停止与清理
stopSpin(): void {
this.isSpinning = false
if (this.spinTimerId !== -1) {
clearInterval(this.spinTimerId)
this.spinTimerId = -1
}
}
// 页面销毁时清理
aboutToDisappear(): void {
this.stopSpin()
}
五、结果判定算法
5.1 问题描述
指针固定在正上方(Canvas坐标 -π/2),转盘旋转 wheelAngle 后停止,求指针指向哪个扇区?
5.2 算法实现
determineResult(): void {
// 指针角度(正上方)
const pointerAngle = -Math.PI / 2
// 每格角度
const segAngle = Math.PI * 2 / 8
// 计算指针相对转盘的位置
let raw = (pointerAngle - this.wheelAngle) / segAngle
// 取模到 [0, 8)
let idx = ((raw % 8) + 8) % 8
idx = Math.floor(idx)
// 获取结果
const prize = this.items[idx] || '未知'
this.result = prize
this.showResult = true
// 记录历史
this.history = [prize, ...this.history].slice(0, 50)
}
5.3 关键点说明
为什么要 + 8 再取模?
JavaScript的 % 运算可能返回负数:
-3 % 8 // 结果是 -3,不是 5
修正方法:
((-3 % 8) + 8) % 8 // 结果是 5
六、自定义选项功能
6.1 编辑弹层
@State showEditor: boolean = false
@State editText: string = ''
openEditor(): void {
this.editText = this.items.join('\n')
this.showEditor = true
}
6.2 保存逻辑
saveItems(): void {
// 分割、去空格、过滤空行
const lines = this.editText
.split('\n')
.map(s => s.trim())
.filter(s => s.length > 0)
// 至少2项
if (lines.length < 2) return
// 最多8项
const newItems = lines.slice(0, 8)
// 不足补默认
while (newItems.length < 8) {
newItems.push(DEFAULT_ITEMS[newItems.length])
}
this.items = newItems
this.showEditor = false
this.wheelAngle = 0
this.drawWheel()
}
七、历史记录功能
7.1 数据结构
@State history: string[] = [] // 新记录在数组头部
7.2 显示记录
if (this.history.length > 0) {
Column() {
Row() {
Text('📋 抽奖记录')
Blank()
Text('清空')
.fontColor('#FF6B35')
.onClick(() => { this.history = [] })
}
ForEach(
this.history.slice(0, 6),
(item: string, idx: number) => {
Row() {
Text(`#${(idx + 1).toString().padStart(2, '0')}`)
Text('🎯')
Text(item)
}
}
)
}
}
八、完整UI结构
build() {
Stack() {
// 主界面
Scroll() {
Column() {
// 标题栏
Row() {
Text('🎡 幸运大转盘')
Blank()
Button('✏️ 编辑')
}
// 转盘
Canvas(this.canvasCtx)
.onReady(() => { this.drawWheel() })
// 按钮
Button(this.isSpinning ? '🎡 抽奖中...' : '🎯 抽奖')
.enabled(!this.isSpinning)
// 结果
if (this.showResult) {
Column() {
Text('🎉 恭喜您获得')
Text(this.result)
}
}
// 历史
if (this.history.length > 0) {
// ...
}
}
}
// 编辑弹层
if (this.showEditor) {
Column() {
// ...
}
.backgroundColor('#80000000')
}
}
}
九、运行效果

十、常见问题
问题1:Canvas 绑制时机错误
症状:在 aboutToAppear 中调用 drawWheel() 报错。
原因:Canvas 尚未初始化。
解决:使用 onReady 回调。
问题2:定时器泄漏
症状:退出页面后转盘仍在转动。
解决:在 aboutToDisappear 中清理定时器。
问题3:文字截断
症状:奖项名称过长,超出扇区。
解决:手动截断并添加省略号。
问题4:结果判定错误
症状:显示结果与指针指向不一致。
解决:修正负数取模逻辑。
十一、总结
技术要点
| 类别 | 知识点 |
|---|---|
| Canvas | 扇形、渐变、阴影、文字旋转 |
| 动画 | 定时器、物理减速 |
| 算法 | 角度计算、取模修正 |
| UI | 状态管理、条件渲染、弹层 |
代码统计
- 主文件:约 350 行
- Canvas绑制:~100 行
- 动画逻辑:~50 行
- UI布局:~200 行
完整代码见项目文件,有问题欢迎评论区讨论!
更多推荐


所有评论(0)