引子:把 12 个图标摆成一个圈

命运转盘是星空运势应用里视觉冲击最强的页面——12 个星座图标围成一圈,点击后转盘飞速旋转,最后停在一个星座上显示今日运势。

听起来就是"画个圆,把图标放上去,转一下"。但实际做起来,光是"把 12 个图标均匀摆成一个圈"就涉及了极坐标到直角坐标的转换,再加上旋转动画的角度计算、停止后结果的反推,每个环节都有数学。

这个页面的代码量不大,但数学含量不低。今天就把转盘的实现拆开讲,重点聊圆形布局的坐标计算和旋转动画的控制逻辑。
完整效果
在这里插入图片描述
在这里插入图片描述

页面结构:标题、转盘、结果三段式

转盘页面的布局很清晰,从上到下分三段:

┌─────────────────────────────┐
│        命运之轮              │
│   转动转盘·揭示今日运势      │
├─────────────────────────────┤
│            ▼(指针)         │
│       ┌─────────────┐       │
│       │  ♈  ♉  ♊   │       │
│       │ ♓      ♌   │       │
│       │  ♋  ✦  ♍   │       │
│       │ ♎      ♑   │       │
│       │  ♏  ♐  ♒   │       │
│       └─────────────┘       │
│                             │
│   🌟 大吉·白羊座             │
│   今日关键词:热情勇敢        │
│                             │
│   ✨ 转动命运之轮            │
└─────────────────────────────┘

核心是中间的转盘区域,用 Stack 组件实现。转盘本身会旋转,但指针(▼)是固定的。

极坐标计算:把图标摆成圆圈

转盘有 12 个星座,需要均匀分布在圆周上。这是个经典的"圆形布局"问题。

坐标转换公式

wheelPos(idx: number): Pos2D {
  const a: number = -Math.PI / 2 + idx * (2 * Math.PI / 12);
  return new Pos2D(
    145 - 28 + 105 * Math.cos(a),
    145 - 28 + 105 * Math.sin(a)
  );
}

这个方法把极坐标(角度、半径)转换成直角坐标(x, y)。拆开来看:

角度计算

const a = -Math.PI / 2 + idx * (2 * Math.PI / 12);
  • -Math.PI / 2:从 12 点钟方向开始(-90 度)
  • idx * (2 * Math.PI / 12):每个星座间隔 30 度(360/12)
  • a 的范围:从 -90 度到 240 度

为什么从 -90 度开始?因为在数学坐标系中,0 度是 3 点钟方向。要让第一个星座出现在 12 点钟方向,需要减去 90 度。

坐标转换

x = 145 - 28 + 105 * Math.cos(a);
y = 145 - 28 + 105 * Math.sin(a);
  • 105:半径,从圆心到星座图标的距离
  • 145:转盘容器的中心坐标(290/2)
  • -28:图标自身宽度的一半(56/2),让图标中心对准坐标点

为什么用 Math.cos 和 Math.sin?

极坐标转直角坐标的公式是:

x = r * cos(θ)
y = r * sin(θ)

这是三角函数的基本应用。在圆形布局中非常常见,比如环形菜单、时钟界面、雷达扫描等。

可视化理解

把 12 个星座想象成钟表的刻度:

        12点 (idx=0, a=-90°)
           ♈
    11点          1点
  ♓                  ♉
10点                  2点
♊                      ♊
  9点 ♎        ♌ 3点
  ♋                  ♍
    8点          4点
  ♑                  ♏
        6点 (idx=6, a=90°)
           ♐

每个星座的位置由角度决定,角度由索引决定。这种"数据驱动"的方式比手动指定坐标更灵活——如果要改成 8 个星座或 16 个星座,只需修改 12 这个数字。

转盘容器:Stack 的层叠结构

转盘用 Stack 组件实现,分三层:

Stack() {
  // 第一层:圆形背景
  Text('◉').fontSize(290).fontColor('#150F30').opacity(0.6)
  
  // 第二层:12 个星座图标
  ForEach(SIGNS, (s: SignData, i: number) => {
    Column() {
      Text(s.emoji).fontSize(18)
      Text(s.name.substring(0, 2)).fontSize(7).fontColor('#BBAAE0').margin({ top: 2 })
    }
    .width(56).height(56).justifyContent(FlexAlign.Center)
    .borderRadius(28).backgroundColor(WHEEL_COLORS[i] + '44')
    .position(this.wheelPos(i))
  })
  
  // 第三层:中心装饰
  Column() {
    Text('✦').fontSize(28).fontColor('#FFD700')
  }
  .width(50).height(50).borderRadius(25)
  .backgroundColor('#0A0820')
  .border({ width: 2, color: '#FFD700', style: BorderStyle.Solid })
}
.width(290).height(290)
.rotate({ angle: this.wheelAngle })
.animation({ duration: this.spinning ? 3500 : 0, curve: Curve.EaseOut })

在这里插入图片描述

三层的作用

层级 内容 作用
底层 圆形 Text ‘◉’ 提供圆形背景,290px 直径
中层 12 个星座图标 通过 .position() 定位到圆周上
顶层 中心装饰 ‘✦’ 视觉焦点,遮挡坐标原点

为什么用 Text ‘◉’ 而不是 Circle 组件?

ArkUI 没有直接的 Circle 组件。要画圆形,可以用:

  1. Text('◉'):最简单,一个字符就是圆形
  2. Canvas:最灵活,可以画任意图形
  3. .borderRadius(半径):把方形组件变成圆形

这里用 Text('◉') 是因为最简单,而且 290px 的圆形字体渲染效果不错。

背景色的选择

#150F30 是深紫色,和整体暗色主题一致。opacity(0.6) 让背景半透明,不会抢走星座图标的视觉焦点。

旋转动画:.rotate() 的核心逻辑

转盘的旋转是这个页面最核心的交互。

旋转的触发

doSpin(): void {
  if (this.spinning) { return; }  // 防止重复点击
  this.spinning = true;
  this.resultIdx = -1;
  this.wheelAngle += 1800 + this.dateSeed(360);
}

在这里插入图片描述

旋转角度的计算

this.wheelAngle += 1800 + this.dateSeed(360);
  • 1800:至少旋转 5 圈(1800/360=5),保证视觉效果
  • this.dateSeed(360):基于日期的随机偏移(0-359 度)

两者相加,总旋转角度在 1800-2159 度之间。每次旋转都累加到 wheelAngle 上,所以转盘会一直朝同一个方向转。

动画的控制

.rotate({ angle: this.wheelAngle })
.animation({ duration: this.spinning ? 3500 : 0, curve: Curve.EaseOut })

关键点

  1. .rotate({ angle: this.wheelAngle }):设置目标角度
  2. .animation():当 wheelAngle 变化时,用 3500ms 的动画过渡到新角度
  3. this.spinning ? 3500 : 0:停止时动画时长为 0,瞬间定位

为什么用 .rotate() 而不是 animateTo()?

.rotate() 是属性动画,声明式写法——只需设置目标角度,框架自动处理过渡。animateTo() 是命令式写法,需要手动控制动画过程。

在这个场景中,.rotate() 更合适,因为:

  • 代码更简洁
  • 动画参数集中在 .animation() 中,容易调整
  • 条件动画(spinning ? 3500 : 0)写法自然

缓动函数的选择

Curve.EaseOut 表示动画结束时减速,开始时加速。这符合物理直觉——转盘启动时快,停止时慢。

如果用 Curve.Linear(匀速),转盘会突然停止,看起来很生硬。如果用 Curve.EaseInOut(两端减速),启动也会慢,不够有冲击力。

停止的逻辑

doReveal(): void {
  if (!this.spinning) { return; }
  const n: number = ((360 - (this.wheelAngle % 360)) % 360 + 360) % 360;
  this.resultIdx = Math.floor(n / 30) % 12;
  this.spinning = false;
}

结果计算的原理

转盘旋转时,指针固定在 12 点钟方向。转盘停下来后,需要计算指针指向哪个星座。

const n = ((360 - (this.wheelAngle % 360)) % 360 + 360) % 360;

这行代码看起来复杂,但逻辑很简单:

  1. this.wheelAngle % 360:当前转盘转过了多少度(模 360)
  2. 360 - (this.wheelAngle % 360):指针到 12 点钟方向的角度差
  3. (... % 360 + 360) % 360:确保结果在 0-359 之间

然后用 Math.floor(n / 30) 把角度转换成星座索引(每个星座占 30 度)。

日期种子算法

dateSeed(max: number): number {
  const d: Date = new Date();
  return (d.getFullYear() * 397 + d.getMonth() * 31 + d.getDate()) % max;
}

这个算法用日期生成伪随机数,保证:

  • 同一天多次旋转,结果相同
  • 不同天结果不同

用质数(397、31)是为了让不同日期的结果尽量分散,避免"连续几天结果一样"的情况。

星座数据:12 个对象的内联定义

const SIGNS: SignData[] = [
  new SignData('白羊座','♈','3.21-4.19','火','热情勇敢·行动力强',
    [new StarPt(100,30,3),new StarPt(60,70,2),...]),
  // ... 共 12 个
];

为什么内联而不是外部文件?

对于这种小型应用,内联数据更方便——所有代码在一个文件里,修改数据不需要切换文件。

但如果数据量很大(比如几百条),建议移到独立文件中,保持主文件简洁。

数据的完整性

每个 SignData 包含:

  • name:星座名称
  • emoji:星座符号
  • date:日期范围
  • elem:元素(火/土/风/水)
  • trait:特质描述
  • stars:星点坐标(用于星图页面)

这种"胖数据"设计让每个星座对象自包含,不需要额外查找。

转盘颜色:12 种颜色的循环

const WHEEL_COLORS: string[] = [
  '#7B4FBF','#3B6FC2','#20A39E','#E89240',
  '#C44569','#5D5FEF','#2E9C7A','#D4594C',
  '#6B5CE7','#3D8FD1','#18A085','#E07030'
];

12 种颜色循环使用,每个星座一个颜色。+ '44' 是添加透明度(44 是十六进制的 26%,约 26% 不透明度)。

为什么用半透明?

半透明背景不会完全遮挡底层的圆形背景,让转盘看起来更有层次感。如果用纯色,图标会显得"贴"在背景上,缺乏深度。

指针的实现

指针是固定在 12 点钟方向的三角形(▼):

Text('▼').fontSize(24).fontColor('#FFD700')

指针不跟随转盘旋转——它在 Stack 外面,不受 .rotate() 影响。这是 Stack 的层叠特性:只有 Stack 内部的子组件才会被旋转。

为什么不把指针放在转盘内部?

如果把指针放在 Stack 内部,它会跟随转盘旋转,用户无法判断指向哪个星座。指针必须固定,转盘旋转才有意义。

结果展示:条件渲染

if (this.resultIdx >= 0) {
  Column() {
    Text(this.resultEmoji()).fontSize(40)
    Text(FORTUNE_LABELS[this.resultIdx] + ' · ' + SIGNS[this.resultIdx].name)
      .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFD700').margin({ top: 6 })
    Text('今日关键词:' + SIGNS[this.resultIdx].trait)
      .fontSize(13).fontColor('#9B8BC0').margin({ top: 6 })
    Text('点击转盘重新占卜').fontSize(10).fontColor('#4B3F70').margin({ top: 10 })
  }
  .width('100%').padding(20).margin({ top: 16 })
  .animation({ duration: 500 })
}

resultIdx 从 -1(未结果)变成有效索引时,结果区域显示。animation({ duration: 500 }) 让结果区域在 500ms 内渐入。

运势标签的映射

const FORTUNE_LABELS: string[] = [
  '大吉','中吉','小吉','末吉','大吉','中吉',
  '小吉','大吉','中吉','末吉','小吉','中吉'
];

12 个运势标签和 12 个星座一一对应。运势结果由转盘停止位置决定,不是随机的。

emoji 映射

resultEmoji(): string {
  if (this.resultIdx < 0) { return ''; }
  const l: string = FORTUNE_LABELS[this.resultIdx];
  if (l === '大吉') { return '🌟'; }
  if (l === '中吉') { return '✨'; }
  if (l === '小吉') { return '⭐'; }
  return '💫';
}

不同的运势等级对应不同的 emoji,增加视觉区分度。

踩坑记录

坑 1:旋转角度的累加

this.wheelAngle += ... 是累加而不是赋值。这意味着转盘永远朝同一个方向转。如果要让转盘可以正反转,需要用赋值而不是累加。

坑 2:动画时长的条件控制

this.spinning ? 3500 : 0 这个条件动画是关键。如果不加这个条件,停止时转盘会用 3500ms 缓慢停到最终位置,视觉效果不好。

坐 3:结果计算的边界

Math.floor(n / 30) % 12 这个取模操作确保结果在 0-11 之间。如果不取模,当 n 接近 360 时,Math.floor(359 / 30) 等于 11,没问题。但如果计算有误,可能出现 12,导致数组越界。

坑 4:指针的位置

指针 的位置需要精确对准转盘的 12 点钟方向。如果转盘容器的尺寸变化,指针位置也需要调整。

代码改进建议

1. 转盘的 Canvas 实现

当前用 Stack + ForEach + .position() 实现转盘。如果要更流畅的旋转效果,可以用 Canvas 组件直接绘制:

Canvas(context) {
  // 绘制圆形背景
  // 绘制星座图标
  // 绘制中心装饰
}
.rotate({ angle: this.wheelAngle })

Canvas 的旋转性能比 Stack 好,因为只需要重绘一个图层,而不是 14 个子组件(1 个背景 + 12 个图标 + 1 个中心)。

2. 指针的动画

当前指针是静态的。如果要更生动的效果,可以给指针添加轻微的摆动动画:

Text('▼')
  .rotate({ angle: this.spinning ? 5 : 0 })
  .animation({ duration: 200, curve: Curve.EaseInOut })

转盘旋转时,指针轻微左右摆动,停止时回正。

3. 音效

转盘旋转时可以添加"嗖嗖"的音效,停止时添加"叮"的音效,增强沉浸感。

4. 触觉反馈

转盘旋转时可以添加轻微的振动反馈,让用户感觉"真的在转"。

总结

命运转盘页面的核心是两个数学问题:极坐标转直角坐标(圆形布局)、角度反推索引(结果计算)。理解了这两个问题,转盘的实现就清晰了。

适用边界:这个页面适合用作 ArkUI 圆形布局和旋转动画的学习案例,涵盖了极坐标计算、Stack 层叠、.rotate() 动画、日期种子算法等核心知识点。但如果要上架应用商店,还需要补充 Canvas 优化、音效、触觉反馈、指针动画等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。

说白了,转盘的实现就三步:算坐标、摆图标、加旋转。坐标算对了,图标自然摆成圈;旋转控制好了,结果自然能算出来。

Logo

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

更多推荐