引子:三张牌的交互比想象中复杂

星空运势应用里,塔罗牌阵页面看着最简单——抽三张牌,翻开看结果。但做起来发现坑不少:牌堆怎么叠出立体感、翻牌动画怎么控制、三张牌的状态怎么独立管理、洗牌算法怎么保证每天结果固定。每个点都不难,但串在一起就容易乱。

这个页面本质上是个小型状态机:未抽牌 → 已抽牌 → 翻开第 N 张 → 全部翻开。状态之间的转换逻辑写清楚了,UI 就自然出来了。
完整效果
在这里插入图片描述

页面结构:三个阶段的 UI 切换

塔罗牌阵页面的 UI 分三个阶段,由 dealttarotOpen[] 两个状态控制:

阶段1:未抽牌
┌─────────────────────────┐
│    塔罗牌阵              │
│    默念问题·点击抽牌      │
│                         │
│    ┌─────────────────┐  │
│    │   ✦ 塔罗(牌堆) │  │
│    └─────────────────┘  │
│    🃏 抽取三张牌         │
│    心中默念你的问题      │
└─────────────────────────┘

阶段2:已抽牌,未翻开
┌─────────────────────────┐
│    过去    现在    未来   │
│   ┌──┐   ┌──┐   ┌──┐  │
│   │✦ │   │✦ │   │✦ │  │
│   │翻│   │翻│   │翻│  │
│   └──┘   └──┘   └──┘  │
│    🔄 重新洗牌           │
└─────────────────────────┘

阶段3:翻开解读
┌─────────────────────────┐
│    过去    现在    未来   │
│   ┌──┐   ┌──┐   ┌──┐  │
│   │🃏│   │🪄│   │📖│  │
│   │愚│   │魔│   │女│  │
│   └──┘   └──┘   └──┘  │
│   ┌─────────────────┐  │
│   │ 过去·愚者        │  │
│   │ 新开始·冒险      │  │
│   │ 新的旅程即将开启… │  │
│   └─────────────────┘  │
│    🔄 重新洗牌           │
└─────────────────────────┘

三个阶段的切换逻辑:

if (!this.dealt) {
  this.StackedDeck();    // 阶段1:牌堆
} else {
  this.DealtCards();     // 阶段2/3:牌面
  this.Readings();       // 阶段3:解读(翻开才显示)
}

在这里插入图片描述

这种"阶段式"的 UI 切换在游戏类应用中很常见——主界面、游戏中、结算画面。关键是把阶段判断集中在一个地方,不要散落到各处。

状态设计:三个 @State 的协作

这个页面用了三个状态来控制 UI:

@State tarotIdx: number[] = [0, 1, 2];    // 三张牌的索引
@State tarotOpen: boolean[] = [false, false, false];  // 是否翻开
@State dealt: boolean = false;             // 是否已抽牌

为什么用数组而不是三个独立变量?

如果用三个独立变量:

@State card1Idx: number = 0;
@State card2Idx: number = 1;
@State card3Idx: number = 2;
@State card1Open: boolean = false;
@State card2Open: boolean = false;
@State card3Open: boolean = false;

代码会很冗长,而且 ForEach 无法复用。用数组可以用循环处理三张牌,代码更简洁。

数组状态的更新陷阱

ArkTS 的 @State 数组有个重要特性:直接修改数组元素不会触发重新渲染

// 错误:不会触发重新渲染
this.tarotOpen[0] = true;

// 正确:创建新数组
const o: boolean[] = [...this.tarotOpen];
o[0] = true;
this.tarotOpen = o;

这是因为 @State 检测的是引用变化,不是内容变化。this.tarotOpen[0] = true 没有改变数组的引用,所以框架检测不到变化。

展开运算符 [...this.tarotOpen] 创建了一个新数组,引用变了,框架才会触发重新渲染。

牌堆视觉:Stack 的层叠技巧

未抽牌时,三张牌叠在一起,用 Stack 实现:

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)

在这里插入图片描述

层叠的视觉原理

四张"牌"从后往前叠放,每张偏移一点:

层级 坐标 背景色 作用
最后 (0, 0) #1C1258 底层
倒数第二 (3, 2) #181050 中间层
倒数第三 (6, 4) #150D48 中间层
最前 (9, 6) 渐变 牌面(带符号)

这种"偏移交错"制造了厚度感,比四张牌完全重叠更真实。颜色从浅到深再到渐变,也增强了立体感。

linearGradient 的用法

最上层的牌面用了线性渐变:

.linearGradient({ angle: 135, colors: [['#1C1258',0],['#2D1F70',1]] })
  • angle: 135:渐变方向,从左上到右下
  • colors:数组格式 [[颜色, 位置], ...],0 是起点,1 是终点

渐变比纯色更有质感,适合做卡牌、按钮等需要"高级感"的元素。

洗牌算法:Fisher-Yates Shuffle

抽牌前需要洗牌,用的是经典的 Fisher-Yates 算法:

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 的核心思想是从后往前遍历,每次随机选一个位置和当前位置交换:

初始:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]

i=20: 随机选 j=5,交换 → [0, 1, 2, 3, 4, 20, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 5]
i=19: 随机选 j=12,交换 → [0, 1, 2, 3, 4, 20, 6, 7, 8, 9, 10, 11, 19, 13, 14, 15, 16, 17, 18, 12, 5]
...

时间复杂度 O(n),空间复杂度 O(1),是洗牌算法的最优解。

为什么不用 Math.random()?

代码用了一个线性同余生成器(LCG)来代替 Math.random()

s = (s * 1103515245 + 12345) % 2147483648;

原因是:同一天洗牌结果固定

Date.now() % 10007 作为种子,同一天运行多次结果相同。这样用户每天看到的三张牌是固定的,但不同天不一样。

如果用 Math.random(),每次刷新都会得到不同的牌,失去了"今日运势"的意义。

只取前三张

this.tarotIdx = [dk[0], dk[1], dk[2]];

洗完牌后只取前三个索引,对应"过去、现在、未来"三张牌。这种"三牌阵"是塔罗牌最经典的牌阵之一。

发牌与翻牌:条件渲染的实战

发牌后的 UI 用条件渲染切换牌面和牌背:

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 })
}

牌面 vs 牌背的视觉差异

属性 牌面 牌背
背景 线性渐变 纯色 #181050
边框 实线金色 虚线紫色
内容 emoji + 名称 ✦ + “点击翻开”
交互 onClick 翻牌

这种对比让用户一眼就能区分已翻开和未翻开的牌。

翻牌逻辑

doFlip(i: number): void {
  if (this.tarotOpen[i]) { return; }  // 已翻开则忽略
  const o: boolean[] = [...this.tarotOpen];
  o[i] = true;
  this.tarotOpen = o;
}

if (this.tarotOpen[i]) { return; } 这行很重要——防止重复点击已翻开的牌。如果不加这个判断,用户可以无限点击,虽然不会出错,但体验不好。

解读区域:数据映射的展示

三张牌全部翻开后,下方显示解读:

Readings() {
  Column() {
    ForEach([0, 1, 2], (i: number) => {
      if (this.tarotOpen[i]) {
        Column() {
          Row() {
            Text(TAROT[this.tarotIdx[i]].emoji).fontSize(26)
            Column() {
              Text(this.posLabel(i) + ' · ' + TAROT[this.tarotIdx[i]].name)
                .fontSize(13).fontWeight(FontWeight.Bold).fontColor('#FFD700')
              Text(TAROT[this.tarotIdx[i]].key)
                .fontSize(10).fontColor('#7B6FBF').margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start).margin({ left: 12 }).layoutWeight(1)
          }
          .width('100%')
          Text(TAROT[this.tarotIdx[i]].text)
            .fontSize(12).fontColor('#BBAAE0').lineHeight(20).margin({ top: 10 })
        }
        .width('100%').padding(14)
        .backgroundColor('#0E0B25').borderRadius(12)
        .border({ width: 0.5, color: '#2D1F58', style: BorderStyle.Solid })
        .margin({ bottom: 10 })
        .animation({ duration: 400 })
      }
    })
  }
  .width('100%').padding({ left: 16, right: 16, top: 14 })
}

数据映射关系

每张牌的显示内容从 TAROT 数组中获取:

tarotIdx[i] → TAROT[索引] → { emoji, name, key, text }
posLabel(i) → "过去" / "现在" / "未来"

这种"索引 → 数据 → UI"的映射模式在列表展示中很常见。好处是 UI 代码和数据完全解耦,修改数据不会影响 UI 逻辑。

动画的编排

解读区域的每张牌都有 animation({ duration: 400 })。当用户翻开一张牌时,对应的解读卡片会在 400ms 内渐入显示。

三张牌的解读是独立的——用户可以先翻"过去",再翻"未来",每翻一张就多显示一个解读卡片。这种"渐进式揭示"比"全部翻开后一次性显示"体验更好。

踩坑记录

坑 1:数组状态的引用问题

ArkTS 的 @State 数组是引用类型。直接修改元素不会触发渲染,必须创建新数组。这是从 React 迁移过来的开发者最容易犯的错误。

// 这样不会触发渲染
this.tarotOpen[0] = true;

// 必须这样
this.tarotOpen = [true, this.tarotOpen[1], this.tarotOpen[2]];
// 或者用展开运算符
this.tarotOpen = [...this.tarotOpen].map((v, idx) => idx === i ? true : v);

坑 2:Stack 的高度计算

Stack 的高度由最高的子组件决定。牌堆的四层牌高度都是 148,但 Stack 容器设置了 170 的高度。多出来的空间是为了让最上层的牌完整显示,不会被裁切。

坑 3:ForEach 的性能

ForEach 会在数据变化时重新渲染所有项。如果牌很多(比如 78 张大阿尔卡纳),性能可能有问题。但三张牌完全没压力。

坑 4:动画的叠加

翻牌时,牌面从虚线边框变成实线金色边框,同时背景从纯色变成渐变。两个属性变化的动画叠加在一起,视觉效果可能不协调。

解决方案是用相同的 duration(350ms)让动画同步,避免"一个快一个慢"的违和感。

代码改进建议

1. 翻牌动画

当前翻牌只是简单的条件渲染切换,没有翻转效果。可以用 3D 旋转实现真正的翻牌动画:

.rotate({ angle: this.tarotOpen[i] ? 0 : 180, axis: { x: 0, y: 1, z: 0 } })

但这需要 ArkUI 的 3D 变换支持,当前版本可能有限制。

2. 音效

抽牌和翻牌可以加音效,增强沉浸感。ArkUI 支持 @ohos.multimedia.media 播放音频。

3. 牌组扩展

当前只有 21 张大阿尔卡纳。如果要支持 78 张完整牌组,需要分"大阿尔卡纳"和"小阿尔卡纳"两类,数据结构也需要扩展。

4. 保存历史

每次抽牌的结果可以保存到本地,让用户回顾。可以用 Preferences API 或关系型数据库。

总结

塔罗牌阵页面虽然功能不复杂,但涉及了 ArkUI 开发的多个核心知识点:状态机设计、数组状态管理、Stack 层叠布局、Fisher-Yates 洗牌算法、条件渲染、动画编排。在实际开发中,这些知识点会反复出现,只是复杂度不同。

适用边界:这个页面适合用作 ArkUI 状态管理和条件渲染的学习案例,涵盖了 @State 数组、Stack 布局、ForEach 渲染、animation 动画等核心知识点。但如果要上架应用商店,还需要补充翻转动画、音效、历史保存、完整牌组等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。

说白了,这个页面的核心就是"状态驱动 UI"——把 dealttarotOpen[] 两个状态管好,UI 就自然出来了。写代码的时候别急着写 UI,先把状态流转图画清楚,事半功倍。

Logo

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

更多推荐