HarmonyOS运动健康——身体数据与成就页面实现详解
个人中心的六个菜单入口中,"身体数据"和"我的成就"是两个纯展示型页面。它们没有复杂的交互逻辑,但在布局方式上有明显差异——身体数据是纵向列表,成就页面是网格。这两个页面恰好代表了 ArkTS 里两种最常用的排列模式,值得拆开看看各自的实现细节。
完整效果

BodyDataPage:列表卡片的数据展示
页面结构
身体数据页面的视觉结构很简单:顶部导航栏,中间六个指标卡片纵向排列,每个卡片占据一行。从截图看,每张卡片左侧是指标名称(小字灰色)和数值(大字彩色),右侧是一个灰色的右箭头,暗示可以点击进入编辑。
整体布局用 Scroll > Column 实现:
Scroll() {
Column() {
this.DataCard('体重', '68.5', 'kg', A)
this.DataCard('身高', '175', 'cm', '#5DADE2')
this.DataCard('BMI', '22.4', '正常', '#00B894')
this.DataCard('体脂率', '18.2', '%', '#FF9F43')
this.DataCard('肌肉量', '52.3', 'kg', '#8A5DFA')
this.DataCard('基础代谢', '1680', '千卡', '#E8734A')
Blank().height(20)
}.width('100%').padding({ left: 16, right: 16 })
}

Scroll 保证了内容超出屏幕时可以上下滑动,Column 里的六个 DataCard 依次纵向排列。最后的 Blank().height(20) 是底部留白,防止最后一张卡片紧贴屏幕边缘。
DataCard Builder 的拆解
@Builder DataCard(label: string, value: string, unit: string, color: string) {
Row() {
Column() {
Text(label).fontSize(13).fontColor(T2)
Row() {
Text(value).fontSize(32).fontWeight(FontWeight.Bold).fontColor(color)
Text(' ' + unit).fontSize(14).fontColor(T3)
}.margin({ top: 4 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])
}.width('100%').padding(18).backgroundColor('#FFFFFF')
.borderRadius(16).margin({ bottom: 10 })
}

逐层分析这个 Builder 的布局嵌套:
最外层 Row:横向排列,layoutWeight(1) 让左侧内容区占据所有剩余空间,右侧的箭头图标被挤到最右边。
中间 Column:纵向排列标签和数值,alignItems(HorizontalAlign.Start) 让文字左对齐。
数值行 Row:把数值和单位放在同一行。数值用 fontSize(32) 的大号加粗字体,单位用 fontSize(14) 的小号灰色字体。这个大小对比制造了"数值为主、单位为辅"的视觉层次。
右侧箭头:SymbolGlyph($r('sys.symbol.chevron_right')) 是系统自带的右箭头符号。用 $r() 引用系统符号资源而不是硬编码路径,保证了在不同设备和系统版本上都能正确渲染。
颜色编码的设计意图
六个指标各有自己的颜色:
| 指标 | 颜色 | 色值 |
|---|---|---|
| 体重 | 红色 | #FF4757 |
| 身高 | 蓝色 | #5DADE2 |
| BMI | 绿色 | #00B894 |
| 体脂率 | 橙色 | #FF9F43 |
| 肌肉量 | 紫色 | #8A5DFA |
| 基础代谢 | 珊瑚色 | #E8734A |
这不是随意配色。红色代表"核心指标"(体重是最重要的身体数据),蓝色代表"基础属性"(身高不会变),绿色代表"健康状态"(BMI 正常是好事),橙色代表"需要关注"(体脂率偏高的话需要警惕),紫色和珊瑚色分别对应肌肉和代谢,和 App 的主色调体系保持一致。
这种"一个指标一个颜色"的做法在数据展示页面里很常见。好处是用户扫一眼就能通过颜色定位到自己关心的指标,不需要逐行阅读文字。坏处是如果指标太多(超过 7-8 个),颜色区分度会下降。目前 6 个指标正好在可区分的范围内。
"编辑"按钮的占位
右上角的红色"编辑"文字目前没有绑定任何点击事件。它是一个 UI 占位——告诉用户"这里以后可以编辑身体数据",但实际功能还没实现。
这种"先放占位,后填逻辑"的做法在 App 开发中很普遍。好处是用户能提前感知到页面的完整功能规划;坏处是如果占位时间太长,用户点了没反应会觉得 App 有 bug。在原型阶段可以接受,上线前必须处理。
AchievementPage:网格布局的成就展示
页面结构
成就页面的视觉和身体数据完全不同——它是两列网格,每个徽章占一个格子。从截图看,8 个徽章排列成 4 行 2 列,已获得的徽章有彩色标签,未获得的被压暗。
整体布局用 Scroll > Column > Grid > ForEach 实现:
Scroll() {
Column() {
Grid() {
ForEach(this.badges, (b: Badge) => {
GridItem() {
// 徽章内容
}
})
}.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)
.width('100%').padding({ left: 16, right: 16 })
Blank().height(20)
}.width('100%')
}
Grid 是 ArkTS 的网格布局组件,columnsTemplate('1fr 1fr') 定义了两列等宽布局。1fr 是 fraction(分数)的缩写,两个 1fr 表示把可用空间平分成两份。columnsGap(10) 设置列间距为 10vp,rowsGap(10) 设置行间距为 10vp。
和身体数据页的 Column 纵向排列不同,Grid 天然支持多行多列,不需要嵌套 Row。这是选择 Grid 而不是 Row + Column 组合的核心原因。
Badge 接口与数据定义
interface Badge {
icon: string
name: string
desc: string
earned: boolean
color: string
}
用 interface 定义徽章的数据结构。在 ArkTS 里 interface 和 type 都能用来定义对象类型,但 interface 在 IDE 里的类型提示更完整——鼠标悬停时能看到每个字段的名称和类型,而 type 有时只显示 object。
@State badges: Badge[] = [
{ icon: '🔥', name: '连续7天', desc: '连续运动7天', earned: true, color: '#FF4757' },
{ icon: '💯', name: '百次训练', desc: '完成100次训练', earned: true, color: '#FF9F43' },
{ icon: '🏃', name: '马拉松', desc: '累计跑步42公里', earned: true, color: '#5DADE2' },
{ icon: '⚡', name: '燃脂达人', desc: '累计消耗10000千卡', earned: true, color: '#FECA57' },
{ icon: '🎯', name: '30天挑战', desc: '连续运动30天', earned: false, color: '#8A5DFA' },
{ icon: '🏆', name: '年度冠军', desc: '年度运动时长第一', earned: false, color: '#E8734A' },
{ icon: '💪', name: '力量之王', desc: '完成500次力量训练', earned: false, color: '#00B894' },
{ icon: '🌟', name: '社区之星', desc: '获得1000个赞', earned: true, color: '#6C5CE7' },
]
8 个徽章,其中 5 个已获得(earned: true),3 个未获得。color 字段用于已获得徽章的标签背景色,每个徽章有自己的主题色。
GridItem 内部的条件渲染
这是成就页面最核心的实现细节——根据 earned 布尔值渲染完全不同的 UI:
GridItem() {
Column() {
Text(b.icon).fontSize(36).margin({ bottom: 8 })
.opacity(b.earned ? 1 : 0.25)
Text(b.name).fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(b.earned ? T1 : T3)
Text(b.desc).fontSize(10).fontColor(T3).margin({ top: 2 })
if (b.earned) {
Text('✓ 已获得').fontSize(9).fontColor(b.color).fontWeight(FontWeight.Bold)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(b.color + '15').borderRadius(6).margin({ top: 6 })
} else {
Row() {}.width(30).height(2).borderRadius(1)
.backgroundColor('#E0DCE8').margin({ top: 8 })
}
}
}
三个关键的视觉差异:
图标透明度:已获得的徽章图标 opacity(1) 正常显示,未获得的 opacity(0.25) 几乎隐形。这让用户一眼就能区分哪些成就已经解锁。
名称颜色:已获得的用深色 T1(#1E1B2E),未获得的用灰色 T3(#BBBBBB)。颜色对比进一步强化了"已完成 vs 未完成"的区分。
底部标签:已获得的显示带颜色的"✓ 已获得"标签,未获得的只放一个小灰色横条。这个横条的作用是占位——如果两个格子一个有标签一个没有,底部会对不齐,视觉上会显得参差不齐。加一个等高的横条保持了网格的整齐。
已获得数量的动态计算
private earnedCount(): number {
let c: number = 0
for (let i: number = 0; i < this.badges.length; i++) {
if (this.badges[i].earned) c++
}
return c
}
右上角显示的"5/8"就是用这个方法计算的。遍历 badges 数组,统计 earned 为 true 的数量。
没有用 filter().length 的写法,原因是 ArkTS 里 Array.filter() 返回的类型推断有时候不够精确,IDE 会报类型警告。手动 for 循环虽然啰嗦一点,但类型安全,不会有问题。
整卡片的透明度控制
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(16).opacity(b.earned ? 1 : 0.6)
除了图标和文字的单独透明度控制,整个 GridItem 的容器也有透明度变化——未获得的卡片整体 opacity(0.6)。这种"双重透明度"(整体 + 局部)制造了一种"未解锁"的视觉效果,比单纯灰掉文字更自然。
两个页面的布局对比
身体数据页和成就页面在布局策略上的差异,本质上是"数据维度"决定的:
| 维度 | 身体数据页 | 成就页面 |
|---|---|---|
| 数据结构 | 扁平列表,每项独立 | 网格矩阵,每项等权 |
| 信息密度 | 一行一项,信息量大 | 一格一项,信息量小 |
| 排列方式 | 纵向 Column | 二维 Grid |
| 交互暗示 | 右箭头→可编辑 | 无箭头→纯展示 |
| 滚动方向 | 纵向 Scroll | 纵向 Scroll(内容可能横向扩展) |
身体数据用列表是因为每个指标有"标签 + 数值 + 单位"三层信息,需要水平空间来展开。成就用网格是因为每个徽章只有"图标 + 名称"两层信息,正方形格子刚好够放。
选错布局方式会导致视觉混乱。如果把身体数据塞进网格,每格里挤三个字段会显得拥挤;如果把成就排成列表,一行一个徽章会浪费大量横向空间,页面会变得很长。
页面导航的统一性
两个页面的导航栏结构完全一致:
Row() {
Row() {
SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1])
}.width(34).height(34).borderRadius(17)
.backgroundColor('rgba(0,0,0,0.03)')
.justifyContent(FlexAlign.Center)
.onClick(() => { router.back() })
Text('页面标题').fontSize(20).fontWeight(FontWeight.Bold)
.fontColor(T1).margin({ left: 10 }).layoutWeight(1)
// 右侧内容
}
34×34 的圆形返回按钮、rgba(0,0,0,0.03) 的浅灰背景、20px 的系统左箭头。这个返回按钮的样式从运动记录页、训练页、饮食记录页一路沿用下来,已经成为整个 App 的标准导航模式。
唯一差异是右侧区域:身体数据页放了红色"编辑"文字,成就页面放了"5/8"计数。这种"左侧固定、右侧灵活"的设计让导航栏在保持一致性的同时,能根据页面需求展示不同的辅助信息。
数据目前是 Mock 的
两个页面的数据目前都是写死在 @State 里的 mock 数据。身体数据的六个指标值、成就的八个徽章,都是硬编码的初始值。
如果以后要接真实数据,改造路径很清晰:
- 身体数据页:从本地存储或服务器获取用户的身体指标,替换
@State的初始值 - 成就页面:根据用户的运动数据计算哪些成就已解锁,动态更新
earned字段
但在当前原型阶段,mock 数据足够用来验证 UI 布局和视觉效果。先把界面做对,再把数据接通——这是大多数 App 开发的实际节奏。
更多推荐


所有评论(0)