HarmonyOS ArkTS 复合动画与多页导航:从星空运势应用看 Swiper、Stack 与旋转动画的协同
引子:一个"玄学"应用背后的技术含量
"星空运势"应用。需求很明确:一个命运转盘,转完显示今日运势;一个星座星图,点击星座能看到星点分布;一个塔罗牌阵,抽三张牌翻开解读。
听起来是个"玄学"应用,但做起来发现技术含量不低——转盘的旋转动画、星图的坐标定位、卡牌的翻转效果,每一个都涉及 ArkUI 的核心机制。更关键的是,这三个功能要在一个页面里通过 Swiper 切换,状态管理变得复杂。
完整效果
技术栈与环境
- 开发框架:HarmonyOS ArkTS (ArkUI)
- 开发工具:DevEco Studio 5.0+
- 目标平台:HarmonyOS NEXT
- 语言:TypeScript 的超集 ArkTS
项目结构:单文件的"重量级"应用
和之前的骰子应用不同,这个星空运势应用把所有代码都放在一个 Index.ets 文件里。这不是最佳实践,但对于演示 ArkUI 的复合功能来说,单文件更方便讲解。
entry/src/main/ets/
├── pages/
│ └── Index.ets # 主页面(所有逻辑都在这里)
├── entryability/
│ └── EntryAbility.ets # 应用入口
└── resources/ # 资源文件
代码量超过 500 行,包含了三个完整的功能模块。如果要做成生产级应用,建议拆分成独立的组件文件,但单文件的好处是所有逻辑一目了然,方便学习。
数据建模:为什么用 class 而不是 interface
代码开头定义了四个类:
class Pos2D {
x: number = 0;
y: number = 0;
constructor(x: number, y: number) { this.x = x; this.y = y; }
}
class StarPt {
x: number = 0;
y: number = 0;
sz: number = 0;
constructor(x: number, y: number, sz: number) { this.x = x; this.y = y; this.sz = sz; }
}
class SignData {
name: string = '';
emoji: string = '';
date: string = '';
elem: string = '';
trait: string = '';
stars: StarPt[] = [];
constructor(n: string, e: string, d: string, el: string, t: string, s: StarPt[]) {
this.name = n; this.emoji = e; this.date = d; this.elem = el; this.trait = t; this.stars = s;
}
}
class TarotInfo {
name: string = '';
emoji: string = '';
key: string = '';
text: string = '';
constructor(n: string, e: string, k: string, t: string) {
this.name = n; this.emoji = e; this.key = k; this.text = t;
}
}

为什么用 class 而不是 interface?
在之前的骰子应用中,我们用 interface 定义数据结构。但这个应用用 class,原因是:
- 需要实例化:
Pos2D和StarPt需要通过new Pos2D(x, y)创建实例,interface不能实例化 - 需要默认值:
class可以给属性设置默认值(x: number = 0),interface不行 - 需要构造函数:
class可以有构造函数,简化实例创建过程
但 class 也有缺点:编译后会生成额外的 JavaScript 代码,增加包体积。对于小项目影响不大,但如果要极致优化,可以用 interface + 工厂函数代替:
interface Pos2D {
x: number;
y: number;
}
function createPos2D(x: number, y: number): Pos2D {
return { x, y };
}
数据设计的考量
SignData 类包含了星座的所有信息:名称、emoji、日期范围、元素、特质、星点坐标。这种"胖数据"设计的好处是:
- 内聚性:所有相关数据都在一个对象里,不会散落在各处
- 可扩展性:如果要添加新字段(比如"幸运数字"),只需修改
SignData类 - 类型安全:编译器会检查所有使用
SignData的地方,确保字段名正确
但也要注意:不要把不相关的数据塞进一个类。比如"转盘颜色"和"星座数据"是两个概念,应该分开管理。
Swiper 组件:多页导航的实现
应用的核心导航是通过 Swiper 组件实现的:
build() {
Column() {
this.TopNav();
Swiper() {
this.WheelPage();
this.StarPage();
this.TarotPage();
}
.index(this.page)
.indicator(false)
.loop(false)
.duration(300)
.onChange((idx: number) => { this.page = idx; })
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
.backgroundColor('#06060F')
}

Swiper vs Tabs
ArkUI 提供了 Swiper 和 Tabs 两种多页导航组件。区别是:
- Swiper:支持手势滑动切换,适合内容独立的页面
- Tabs:底部或顶部 Tab 导航,适合需要频繁切换的页面
这个应用用 Swiper 而不是 Tabs,原因是:
- 手势交互:用户可以左右滑动切换页面,体验更流畅
- 视觉效果:
Swiper的切换动画更平滑 - 状态同步:通过
onChange事件同步page状态,和顶部导航联动
Swiper 的关键属性
Swiper() { ... }
.index(this.page) // 当前页索引
.indicator(false) // 隐藏默认指示器
.loop(false) // 禁用循环滑动
.duration(300) // 切换动画时长
.onChange((idx) => { }) // 页面切换回调
.layoutWeight(1) // 占据剩余空间
为什么禁用 loop?
如果启用 loop,Swiper 会在首尾添加"克隆页",实现无限循环。但对于三个独立功能的页面,循环滑动没有意义,反而可能导致状态混乱。
为什么隐藏 indicator?
默认的指示器是小圆点,但这个应用有自定义的顶部导航栏,保留默认指示器会重复。通过 .indicator(false) 隐藏,用自定义 UI 代替。
命运转盘:旋转动画的核心实现
转盘是这个应用最复杂的部分,涉及旋转动画、坐标计算和状态管理。
转盘的布局结构
Stack() {
// 底部圆形背景
Text('◉').fontSize(290).fontColor('#150F30').opacity(0.6)
// 星座图标(通过坐标定位)
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 })

这里用了 Stack 组件来叠加三个层次:背景、星座图标、中心装饰。Stack 的特点是子组件可以重叠,适合做这种"层叠"效果。
坐标计算:wheelPos 方法
转盘有 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)
);
}
这个方法把极坐标转换成直角坐标:
- 角度计算:从
-Math.PI / 2(12 点钟方向)开始,每个星座间隔2 * Math.PI / 12(30 度) - 半径:105 像素(从中心到星座图标的距离)
- 偏移:
145 - 28是为了把坐标原点从 Stack 左上角移到中心
这种"极坐标转直角坐标"的方法在圆形布局中很常见,掌握后可以做很多效果(比如环形菜单、时钟界面)。
旋转动画:.rotate() 和 .animation()
转盘的旋转效果是通过 .rotate() 和 .animation() 配合实现的:
.rotate({ angle: this.wheelAngle })
.animation({ duration: this.spinning ? 3500 : 0, curve: Curve.EaseOut })
为什么用 .rotate() 而不是 animateTo()?
.rotate() 是属性动画,当 wheelAngle 变化时自动添加过渡效果。而 animateTo() 是显式动画,需要手动控制。
在这个场景中,.rotate() 更合适,因为:
- 声明式:只需设置目标角度,框架自动处理过渡
- 条件动画:通过
this.spinning ? 3500 : 0控制动画时长,停止时不播放动画 - 缓动函数:
Curve.EaseOut让动画结束时减速,更自然
旋转逻辑:doSpin 和 doReveal
doSpin(): void {
if (this.spinning) { return; }
this.spinning = true;
this.resultIdx = -1;
this.wheelAngle += 1800 + this.dateSeed(360);
}
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;
}
旋转角度的计算:
1800 + this.dateSeed(360) 保证至少旋转 5 圈(1800 度),再加上一个基于日期的随机偏移(0-359 度)。这样每天的结果不同,但同一天内结果固定。
结果的计算:
doReveal 方法计算指针指向哪个星座。关键是 (360 - (this.wheelAngle % 360)) % 360——因为转盘是逆时针旋转,指针是固定的,所以要用 360 减去当前角度。
日期种子:dateSeed 方法
dateSeed(max: number): number {
const d: Date = new Date();
return (d.getFullYear() * 397 + d.getMonth() * 31 + d.getDate()) % max;
}
这个方法用日期生成一个伪随机数,保证每天的结果固定。用质数(397、31)是为了让不同日期的结果尽量分散。
为什么不直接用 Math.random()?因为用户可能多次旋转,如果用真随机,每次结果都不同,失去了"今日运势"的意义。
星座星图:Grid 布局与坐标定位
星座星图页面用了 Grid 组件来展示 12 个星座:
Grid() {
ForEach(SIGNS, (s: SignData, i: number) => {
GridItem() {
Column() {
Text(s.emoji).fontSize(i === this.selectedSign ? 36 : 28)
Text(s.name).fontSize(10)
.fontColor(i === this.selectedSign ? '#FFD700' : '#8B7BC0')
Text(s.date).fontSize(8).fontColor('#4B3F70').margin({ top: 2 })
}
.width('100%').height(90).justifyContent(FlexAlign.Center)
.borderRadius(12)
.backgroundColor(i === this.selectedSign ? '#1C1545' : '#0E0B25')
.border({ width: i === this.selectedSign ? 1 : 0.5, color: ... })
.onClick(() => {
if (this.selectedSign === i) { this.selectedSign = -1; }
else { this.selectedSign = i; }
})
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(10).columnsGap(10)
Grid vs Flex
为什么用 Grid 而不是 Flex?
- Grid:适合二维布局,可以精确控制列数和间距
- Flex:适合一维布局(行或列),需要嵌套才能实现二维
这里需要 3 列 4 行的网格,Grid 更合适。.columnsTemplate('1fr 1fr 1fr') 定义了三等分的列宽,rowsGap 和 columnsGap 定义了间距。
星点的坐标定位
点击星座后,会显示星图详情,星点通过坐标定位:
ForEach(SIGNS[this.selectedSign].stars, (pt: StarPt) => {
Text('·')
.fontSize(pt.sz * 2.5).fontColor('#FFFFFF')
.position(new Pos2D(pt.x - 2, pt.y - 2))
})
每个星点的位置和大小都存储在 SignData.stars 数组中。.position() 方法把星点定位到指定坐标,pt.sz * 2.5 控制大小。
这种"数据驱动"的布局方式很灵活——如果要修改星图,只需修改数据,不需要改 UI 代码。
塔罗牌阵:卡牌翻转的实现
塔罗牌阵是最有趣的部分,涉及卡牌的堆叠、发牌和翻转动画。
牌堆的视觉效果
@Builder
StackedDeck() {
Stack() {
Column().width(100).height(148).borderRadius(12).backgroundColor('#1C1258')
.border({ width: 1, color: '#5B4FAF', style: BorderStyle.Solid })
.position(new Pos2D(0, 0))
Column().width(100).height(148).borderRadius(12).backgroundColor('#181050')
.border({ width: 1, color: '#4B3F8F', style: BorderStyle.Solid })
.position(new Pos2D(3, 2))
Column().width(100).height(148).borderRadius(12).backgroundColor('#150D48')
.border({ width: 1, color: '#3B2F78', style: BorderStyle.Solid })
.position(new Pos2D(6, 4))
Column() {
Text('✦').fontSize(36).fontColor('#8B7BC0')
Text('塔 罗').fontSize(12).fontColor('#5B4FAF').margin({ top: 6 })
}
.width(100).height(148).justifyContent(FlexAlign.Center).borderRadius(12)
.linearGradient({ angle: 135, colors: [['#1C1258',0],['#2D1F70',1]] })
.border({ width: 1.5, color: '#7B6FBF', style: BorderStyle.Solid })
.position(new Pos2D(9, 6))
}
.width(120).height(170)
}
这里用 Stack 叠加了四层,每层偏移一点,制造"牌堆"的视觉效果。最上层用了 linearGradient(线性渐变),让牌面更有质感。
卡牌的翻转逻辑
doFlip(i: number): void {
if (this.tarotOpen[i]) { return; }
const o: boolean[] = [...this.tarotOpen];
o[i] = true;
this.tarotOpen = o;
}
翻转逻辑很简单:点击未翻开的牌,把对应的 tarotOpen[i] 设为 true。
为什么要用展开运算符 [...this.tarotOpen]?
因为 @State 数组是引用类型,直接修改 this.tarotOpen[i] = true 不会触发重新渲染。必须创建新数组,框架才能检测到变化。
发牌动画
@Builder
DealtCards() {
Column() {
Row() {
ForEach([0, 1, 2], (i: number) => {
Column() {
Text(this.posLabel(i)).fontSize(10).fontColor('#5B4FAF')
.fontWeight(FontWeight.Medium).margin({ bottom: 8 })
if (this.tarotOpen[i]) {
// 已翻开:显示牌面
Column() {
Text(TAROT[this.tarotIdx[i]].emoji).fontSize(38)
Text(TAROT[this.tarotIdx[i]].name)
.fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FFD700').margin({ top: 4 })
}
.width(94).height(138).justifyContent(FlexAlign.Center).borderRadius(12)
.linearGradient({ angle: 180, colors: [['#1C1258',0],['#2D1F70',1]] })
.border({ width: 1, color: '#FFD700', style: BorderStyle.Solid })
.animation({ duration: 350 })
} else {
// 未翻开:显示牌背
Column() {
Text('✦').fontSize(32).fontColor('#8B7BC0')
Text('点击翻开').fontSize(9).fontColor('#5B4FAF').margin({ top: 6 })
}
.width(94).height(138).justifyContent(FlexAlign.Center).borderRadius(12)
.backgroundColor('#181050')
.border({ width: 1, color: '#5B4FAF', style: BorderStyle.Dashed })
.onClick(() => { this.doFlip(i); })
.animation({ duration: 350 })
}
}
.alignItems(HorizontalAlign.Center).margin({ left: 5, right: 5 })
})
}
.width('100%').justifyContent(FlexAlign.Center)
}
.width('100%')
}
这里用了条件渲染来切换牌面和牌背。animation({ duration: 350 }) 让切换过程有过渡效果。
洗牌算法:Fisher-Yates Shuffle
shuffle(): void {
const dk: number[] = [];
for (let i: number = 0; i < TAROT.length; i++) { dk.push(i); }
let s: number = Date.now() % 10007;
for (let i: number = dk.length - 1; i > 0; i--) {
s = (s * 1103515245 + 12345) % 2147483648;
const j: number = s % (i + 1);
const t: number = dk[i]; dk[i] = dk[j]; dk[j] = t;
}
this.tarotIdx = [dk[0], dk[1], dk[2]];
this.tarotOpen = [false, false, false];
this.dealt = false;
}
这是经典的 Fisher-Yates 洗牌算法,时间复杂度 O(n),空间复杂度 O(1)。
为什么不用 Math.random()?
和转盘一样,用日期种子保证每天的洗牌结果固定。但这里用了一个简单的线性同余生成器(LCG),而不是 Math.random(),原因是:
- 可重现:同一天运行多次,结果相同
- 性能:LCG 比 Math.random() 快
- 可控:可以调整参数控制随机性
踩坑记录
在开发过程中,遇到了几个值得注意的问题:
坑 1:Stack 的坐标系
Stack 组件的坐标原点在左上角,而不是中心。如果要让星座图标围绕中心旋转,需要手动计算偏移量。代码中 145 - 28 就是为了把坐标原点移到中心。
坑 2:animation 的触发时机
.animation() 只在属性值变化时触发。如果属性值不变,动画不会播放。比如转盘停止后,wheelAngle 不再变化,即使再次点击"停止"按钮,也不会有动画效果。
坑 3:数组状态的更新
直接修改 @State 数组的某个元素不会触发重新渲染。必须创建新数组(用展开运算符或 slice()),框架才能检测到变化。这是 ArkTS 和 React 的一个重要区别。
坑 4:Swiper 的性能
Swiper 组件会预加载相邻页面,如果页面很复杂,可能导致内存占用过高。可以通过 .cachedCount(1) 控制预加载数量,但会影响切换流畅度。
代码改进建议
虽然这个应用功能完整,但还有一些可以优化的地方:
1. 组件拆分
把三个页面拆分成独立的组件:
@Component
struct WheelPage { ... }
@Component
struct StarPage { ... }
@Component
struct TarotPage { ... }
这样每个组件职责单一,也方便独立测试。
2. 数据分离
把星座数据和塔罗数据移到独立的文件中:
// data/Signs.ts
export const SIGNS: SignData[] = [ ... ];
// data/Tarot.ts
export const TAROT: TarotInfo[] = [ ... ];
这样主页面更简洁,数据也更容易维护。
3. 主题系统
当前颜色值散落在代码各处。建议像骰子应用一样,用主题系统统一管理颜色。
4. 性能优化
转盘的旋转动画每帧都会重新渲染 12 个星座图标。如果性能有问题,可以用 Canvas 组件来绘制转盘,减少 UI 层级。
总结
这个星空运势应用虽然功能简单,但涉及了 ArkUI 开发的多个核心知识点:Swiper 多页导航、Stack 坐标布局、旋转动画、Grid 网格布局、卡牌翻转效果、Fisher-Yates 洗牌算法。在实际开发中,这些知识点会反复出现,只是复杂度不同。
适用边界:这个应用适合用作 ArkUI 复合动画和复杂布局的学习案例,涵盖了 Swiper、Stack、Grid、旋转动画、坐标计算等核心知识点。但如果要上架应用商店,还需要补充性能优化、组件拆分、主题系统、错误处理、无障碍支持等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。
对于 ArkTS 新手,建议从类似的小项目入手,逐步理解框架的设计哲学。ArkTS 的声明式 UI 和 React 有相似之处,但状态管理和生命周期有明显区别,需要花时间适应。
对于有经验的开发者,重点是理解 ArkTS 的约束——它不是 TypeScript 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。
更多推荐



所有评论(0)