HarmonyOS ArkTS 星级评分与卡片式布局:从转盘运势详解看结构化数据的可视化表达
引子:转完转盘,用户还想看什么
第一版的转盘页面,转完之后只显示两行字——“大吉·白羊座"和"今日关键词:热情勇敢”。用户看一眼就没了,没有继续探索的动力。
优化后加了运势详解:爱情、事业、健康、财运四个维度,每个带星级评分和一段解读。底下还加了今日幸运物——幸运色、守护石、吉时、方位。转完转盘后,用户会往下滚动看详情,停留时间明显变长。
完整效果


数据结构:运势结果的建模
先看数据结构。运势结果需要三个核心元素:运势等级、四维评分、幸运物品。
// 运势等级
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
运势数据是只读的,不需要实例化方法,用 interface 比 class 更轻量。interface 编译后不会生成额外的 JavaScript 代码,包体积更小。
为什么四维数据用 interface 数组
如果用四个独立变量(loveStars、careerStars、healthStars、wealthStars),代码会很冗长,而且 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 的优势在于:
- 代码简单:一个
Text组件就行,不需要额外的样式代码 - 性能好:emoji 是字体渲染,比自定义组件快
- 兼容性: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 嵌套更灵活:
- 高度自适应:当某个卡片的解读文本特别长时,卡片高度会自动撑开,不会出现 Grid 布局中高度不一致的问题
- 响应式:在大屏设备上,卡片可以保持全宽,而不是被 Grid 强制分成两列
- 代码简单:不需要计算 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) 让四个方块在屏幕中间排列,而不是靠左。这是因为幸运物是"附加信息",居中排列更符合视觉习惯。
三个文字的层次
每个方块里有三个文字元素:
- emoji:
fontSize(20),最大,视觉焦点 - 标题:
fontSize(9),最小,#6B5B9A暗色,弱化 - 内容:
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:卡片高度不一致
如果四个卡片的解读文本长度差异很大,卡片高度会不一致,视觉上参差不齐。解决方案有两种:
- 固定最小高度:给卡片设
minHeight(80),确保最矮的卡片也有一定高度 - 截断文本:给解读文本设
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 数据驱动展示的学习案例,涵盖了星级评分、卡片布局、弹性排列、条件渲染、动画配合等核心知识点。但如果要上架应用商店,还需要补充组件化、数据持久化、自定义星级、响应式布局、点击交互等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。
更多推荐



所有评论(0)