HarmonyOS NEXT Canvas 实战:从零开发抽奖转盘应用

前言

Canvas 绑制是移动开发中一项重要的技能,无论是游戏开发、数据可视化还是创意交互,都离不开它。本文将带你从零开始,使用 HarmonyOS NEXT 的 Canvas API 开发一个完整的抽奖转盘应用。

项目信息

  • 包名:com.example.myapplication
  • API版本:HarmonyOS NEXT (API 23+)
  • 核心技术:Canvas 2D、动画系统、状态管理

一、需求分析

1.1 功能需求

功能模块 描述
转盘显示 8格彩色扇形,Canvas绑制
抽奖动画 减速旋转,自然停止
结果判定 精确计算指针指向的扇区
自定义奖项 用户可编辑奖项内容
历史记录 保存抽奖历史,可清空

1.2 技术难点

  1. Canvas 扇形绘制:如何正确绘制等分扇形并填充颜色
  2. 文字旋转:如何让每个扇形的文字沿半径方向显示
  3. 动画系统:如何实现物理减速效果
  4. 结果算法:如何精确判断指针指向的扇区

二、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()
}

变换过程

  1. translate(cx, cy):将坐标系原点移到圆心
  2. rotate(angle):旋转坐标系到扇区中间
  3. 绘制文字时,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 行

完整代码见项目文件,有问题欢迎评论区讨论!

Logo

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

更多推荐