引子:转完转盘,用户还想看什么

第一版的转盘页面,转完之后只显示两行字——“大吉·白羊座"和"今日关键词:热情勇敢”。用户看一眼就没了,没有继续探索的动力。

优化后加了运势详解:爱情、事业、健康、财运四个维度,每个带星级评分和一段解读。底下还加了今日幸运物——幸运色、守护石、吉时、方位。转完转盘后,用户会往下滚动看详情,停留时间明显变长。

完整效果
在这里插入图片描述
在这里插入图片描述

数据结构:运势结果的建模

先看数据结构。运势结果需要三个核心元素:运势等级、四维评分、幸运物品。

// 运势等级
const FORTUNE_LABELS: string[] = [
  '大吉','中吉','小吉','末吉','大吉','中吉',
  '小吉','大吉','中吉','末吉','小吉','中吉'
];

// 四维评分数据
interface FortuneDetail {
  category: string;    // 维度名称
  emoji: string;       // 图标
  stars: number;       // 星级(1-5)
  text: string;        // 解读文本
}

// 幸运物数据
interface LuckyItem {
  emoji: string;
  label: string;
  value: string;
}

在这里插入图片描述

为什么用 interface 而不是 class

运势数据是只读的,不需要实例化方法,用 interfaceclass 更轻量。interface 编译后不会生成额外的 JavaScript 代码,包体积更小。

为什么四维数据用 interface 数组

如果用四个独立变量(loveStarscareerStarshealthStarswealthStars),代码会很冗长,而且 ForEach 无法复用。用 FortuneDetail[] 数组可以用循环渲染,代码更简洁。

数据和 UI 的分离

运势数据定义在 model/DataModel.ets 里,UI 渲染在 view/WheelView.ets 里。修改数据不需要改 UI,修改 UI 不会影响数据。这种分离是模块化的核心思想。

星级评分:满星、半星、空星的视觉表达

星级评分是这个页面最有意思的部分——用 emoji 字符组合出满星、半星、空星的效果。

渲染逻辑

// 满星
Text('★').fontSize(11).fontColor('#FFD700')

// 半星
Text('☆').fontSize(11).fontColor('#FFD700')

// 空星
Text('☆').fontSize(11).fontColor('#3B2F78')

满星用金色实心星(★),半星用金色空心星(☆),空星用深色空心星(☆)。通过颜色区分"有"和"无",而不是用不同的字符。

循环渲染的实现

Row() {
  ForEach([1, 2, 3, 4, 5], (n: number) => {
    if (n <= stars) {
      // 满星
      Text('★').fontSize(11).fontColor('#FFD700')
    } else if (n - 0.5 <= stars) {
      // 半星
      Text('☆').fontSize(11).fontColor('#FFD700')
    } else {
      // 空星
      Text('☆').fontSize(11).fontColor('#3B2F78')
    }
  })
}

遍历 1 到 5,根据当前序号和星级的关系决定显示哪种星:

  • n <= stars:满星(比如 stars=4,n=1/2/3/4 都是满星)
  • n - 0.5 <= stars:半星(比如 stars=4.5,n=5 时 5-0.5=4.5 <= 4.5,显示半星)
  • 其他:空星

为什么用 emoji 而不是自定义组件

Text 组件加背景色也能实现星星效果(比如用 linearGradient 做半星),但 emoji 的优势在于:

  1. 代码简单:一个 Text 组件就行,不需要额外的样式代码
  2. 性能好:emoji 是字体渲染,比自定义组件快
  3. 兼容性:emoji 在所有设备上都能正常显示

缺点是半星的视觉效果不如自定义组件精确——emoji 的半星只是空心星,不是真正的"一半实心一半空心"。但对于运势应用来说,用户不会纠结半星的精确度。

为什么不用 0.5 步进

有些评分系统用 0.5 步进(比如 4.5 分),但这个应用用整数步进(1-5 星)。原因是运势评分是主观的,不需要精确到半星。用整数更简洁,用户也更容易理解。

卡片式布局:四个维度的排列

运势详解的四个维度用卡片式布局排列——每个维度一个独立卡片,包含图标、标题、星级和解读文本。

卡片的结构

ForEach(fortuneDetails, (item: FortuneDetail) => {
  Column() {
    Row() {
      // 第一行:图标 + 标题 + 星级
      Text(item.emoji).fontSize(14)
      Text(item.category)
        .fontSize(13).fontWeight(FontWeight.Bold).fontColor('#E8DFF8')
        .margin({ left: 6 })
      Blank()  // 弹性空白,把星级推到右边
      // 星级渲染(上面讲的 ForEach 逻辑)
    }
    .width('100%')
    
    // 第二行:解读文本
    Text(item.text)
      .fontSize(11).fontColor('#BBAAE0')
      .lineHeight(18).margin({ top: 6 })
  }
  .width('100%').padding(12)
  .backgroundColor('#0E0B25').borderRadius(10)
  .border({ width: 0.5, color: '#1C1545', style: BorderStyle.Solid })
  .margin({ bottom: 8 })
})

为什么用 Row 嵌套而不是 Grid

四维卡片可以用 Grid 的两列两行布局,但 Row 嵌套更灵活:

  1. 高度自适应:当某个卡片的解读文本特别长时,卡片高度会自动撑开,不会出现 Grid 布局中高度不一致的问题
  2. 响应式:在大屏设备上,卡片可以保持全宽,而不是被 Grid 强制分成两列
  3. 代码简单:不需要计算 Grid 的列宽和行高

Blank() 的作用

Blank() 是 ArkUI 的弹性空白组件,会自动填充剩余空间。在 Row 里用 Blank() 可以让左边的图标+标题和右边的星级分开,不需要手动计算间距。

Row() {
  Text(item.emoji)        // 左边:图标
  Text(item.category)     // 左边:标题
  Blank()                 // 中间:弹性空白
  // 星级                   // 右边:星级
}

如果不加 Blank(),图标、标题、星级会挤在一起。如果用 margin({ left: 'auto' }) 也能实现右对齐,但 Blank() 更语义化。

间距和对齐

  • 卡片之间:margin({ bottom: 8 }) 留 8px 间距
  • 卡片内部:padding(12) 留 12px 内边距
  • 标题和星级:Row 水平排列,Blank() 分隔
  • 解读文本:margin({ top: 6 }) 和标题行留 6px 间距

背景色和边框

  • 背景色 #0E0B25:深紫色,和页面背景 #06060F 有区分
  • 边框 0.5px #1C1545:极细的边框,增加层次感但不抢眼
  • 圆角 borderRadius(10):柔和的圆角,避免直角的生硬

幸运物:四个小方块的实现

幸运物区域用四个小方块并排展示,每个方块包含 emoji 图标、标题和具体内容。

数据结构

const LUCKY_ITEMS: LuckyItem[] = [
  { emoji: '🎨', label: '幸运色', value: '蓝色' },
  { emoji: '💎', label: '守护石', value: '青金石' },
  { emoji: '🕐', label: '吉时', value: '下午4点' },
  { emoji: '🧭', label: '方位', value: '北方' },
];

布局实现

Row() {
  ForEach(LUCKY_ITEMS, (item: LuckyItem) => {
    Column() {
      Text(item.emoji).fontSize(20)
      Text(item.label).fontSize(9).fontColor('#6B5B9A').margin({ top: 4 })
      Text(item.value).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#E8DFF8').margin({ top: 2 })
    }
    .width('22%').padding({ top: 8, bottom: 8 })
    .backgroundColor('#0E0B25').borderRadius(8)
    .border({ width: 0.5, color: '#1C1545', style: BorderStyle.Solid })
    .margin({ left: 4, right: 4 })
  })
}
.width('100%').justifyContent(FlexAlign.Center)

为什么用 22% 而不是 25%

四个方块如果用 25% 宽度,加上 margin 的间距,总宽度会超过 100%,导致换行。用 22% 留出余量,确保四个方块在同一行。

居中对齐

.justifyContent(FlexAlign.Center) 让四个方块在屏幕中间排列,而不是靠左。这是因为幸运物是"附加信息",居中排列更符合视觉习惯。

三个文字的层次

每个方块里有三个文字元素:

  1. emojifontSize(20),最大,视觉焦点
  2. 标题fontSize(9),最小,#6B5B9A 暗色,弱化
  3. 内容fontSize(11),中等,#E8DFF8 亮色,突出

这种"大-小-中"的文字层次让用户先看到 emoji(吸引注意力),再看到内容(获取信息),最后看到标题(辅助理解)。

数据映射:从数据到 UI 的完整流程

整个运势详解的数据流是:

转盘停止 → 计算星座索引 (resultIdx)
    ↓
从 SIGNS 数组获取星座数据
    ↓
从 FORTUNE_LABELS 获取运势等级
    ↓
从 fortuneDetails 获取四维评分
    ↓
从 LUCKY_ITEMS 获取幸运物
    ↓
ForEach 渲染卡片和方块

这种"数据 → UI"的单向流是声明式 UI 的核心思想。开发者只需要关心数据结构和渲染逻辑,不需要手动操作 DOM。

条件渲染的触发

运势详解只在转盘停止后显示:

if (this.resultIdx >= 0) {
  // 运势详解区域
  Column() {
    // 运势卡片
    // 四维评分
    // 幸运物
  }
  .animation({ duration: 300 })
}

resultIdx 从 -1 变为有效值时,详情区域渲染;变回 -1 时,详情区域销毁。

动画的配合

详情区域加了整体渐入动画,时长 300ms。动画加在容器上而不是每个子组件上,让整个区域作为一个整体渐入,避免四个卡片同时动画导致的视觉混乱。

踩坑记录

坑 1:星级的边界处理

星级评分可能出现 0 星或 5 星以上的情况。代码中需要确保星级在 0 到 5 之间:

const clampedStars = Math.max(0, Math.min(5, item.stars));

如果不做边界处理,0 星会显示 5 个空星(看起来像没评分),6 星会导致数组越界。

坑 2:卡片高度不一致

如果四个卡片的解读文本长度差异很大,卡片高度会不一致,视觉上参差不齐。解决方案有两种:

  1. 固定最小高度:给卡片设 minHeight(80),确保最矮的卡片也有一定高度
  2. 截断文本:给解读文本设 maxLines(2)textOverflow(TextOverflow.Ellipsis),超过两行显示省略号

当前版本没有做这两个处理,因为运势解读文本长度差异不大。

坑 3:幸运物的换行

在窄屏设备上(比如宽度 < 320px),四个方块可能会换行。解决方案是用 Scroll 组件包裹:

Scroll() {
  Row() {
    ForEach(LUCKY_ITEMS, (item: LuckyItem) => {
      // 方块
    })
  }
}
.scrollable(ScrollDirection.Horizontal)

但当前版本的方块宽度(22%)在大多数设备上都能一行显示,不加滚动也能接受。

坑 4:星级的半星渲染

emoji 的半星(☆)和满星(★)在视觉上差异不大,用户可能分不清。如果要更精确的半星效果,可以用 Canvas 组件绘制:

Canvas(context) {
  // 绘制满星部分
  // 绘制空星部分
}
.clipContent(true)

但实现复杂度会大幅增加,对于运势应用来说不值得。

坑 5:ForEach 的 key 管理

ForEach 遍历四维数据时,需要确保每个元素有唯一的 key。如果数据中有重复项(比如两个维度名称相同),可能会导致渲染错误。解决方案是用索引作为 key:

ForEach(fortuneDetails, (item: FortuneDetail, i: number) => {
  // 卡片
}, (item: FortuneDetail, i: number) => `fortune_${i}`)

代码改进建议

1. 星级组件化

星级评分的渲染逻辑可以提取成独立组件:

@Component
struct StarRating {
  @Prop stars: number = 0;
  
  build() {
    Row() {
      ForEach([1, 2, 3, 4, 5], (n: number) => {
        Text(n <= this.stars ? '★' : '☆')
          .fontSize(11)
          .fontColor(n <= this.stars ? '#FFD700' : '#3B2F78')
      })
    }
  }
}

复用时只需要 <StarRating stars={item.stars} />,代码更简洁。

2. 数据持久化

运势结果可以存到本地,让用户可以回顾历史运势。可以用 Preferences API:

import dataPreferences from '@ohos.data.preferences';

// 保存
await preferences.put('lastFortune', JSON.stringify(fortuneData));

// 读取
const lastFortune = await preferences.get('lastFortune', '');

3. 自定义星级样式

如果要更精致的星级效果,可以用自定义组件绘制满星、半星、空星。可以用 Canvas 或者 Stack + linearGradient 实现半星效果。

4. 响应式布局

幸运物区域在大屏设备上可以改成两行两列布局:

Grid() {
  ForEach(LUCKY_ITEMS, (item: LuckyItem) => {
    GridItem() {
      // 方块
    }
  })
}
.columnsTemplate('1fr 1fr')
.rowsTemplate('1fr 1fr')

5. 运势卡片的点击交互

当前运势卡片是静态的。如果要更丰富的交互,可以点击卡片展开更多解读:

.onClick(() => {
  this.expandedCategory = this.expandedCategory === item.category ? '' : item.category;
})

总结

转盘运势详解的核心是"数据驱动展示"——把结构化数据用星级评分、卡片式布局、方块网格可视化。四维评分让用户快速了解运势重点,幸运物提供实用参考。

星级评分的关键是条件渲染——根据分数决定显示满星、半星还是空星。卡片式布局的关键是弹性排列——让内容自动撑开高度,保持视觉整齐。幸运物的关键是等宽排列——让四个方块看起来像一组。

这个模式适用于任何需要"评分 + 解读"的场景:餐厅评价、电影评分、商品评价。核心思路一样,只是数据内容不同。

适用边界:这个部分适合用作 ArkUI 数据驱动展示的学习案例,涵盖了星级评分、卡片布局、弹性排列、条件渲染、动画配合等核心知识点。但如果要上架应用商店,还需要补充组件化、数据持久化、自定义星级、响应式布局、点击交互等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。

Logo

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

更多推荐