HarmonyOS ArkTS 圆形布局与旋转动画:从命运转盘页面看极坐标计算与属性动画的实战技巧
引子:把 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 组件。要画圆形,可以用:
Text('◉'):最简单,一个字符就是圆形Canvas:最灵活,可以画任意图形.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 })
关键点:
.rotate({ angle: this.wheelAngle }):设置目标角度.animation():当wheelAngle变化时,用 3500ms 的动画过渡到新角度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;
这行代码看起来复杂,但逻辑很简单:
this.wheelAngle % 360:当前转盘转过了多少度(模 360)360 - (this.wheelAngle % 360):指针到 12 点钟方向的角度差(... % 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 优化、音效、触觉反馈、指针动画等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。
说白了,转盘的实现就三步:算坐标、摆图标、加旋转。坐标算对了,图标自然摆成圈;旋转控制好了,结果自然能算出来。
更多推荐



所有评论(0)