HarmonyOS运动健康——首页仪表盘架构拆解

📅 2026/7/11 20:21:09 👁️ 阅读次数 📝 编程学习
HarmonyOS运动健康——首页仪表盘架构拆解

换了个方向做运动健康App。和购物商城的"信息密度"不同,运动健康的核心是"数据可视化"——用户一眼看到今天走了多少步、消耗了多少卡路里、运动了多长时间。首页仪表盘的任务就是在一屏内展示这些数据,同时提供快速入口。

这个页面的布局逻辑是:顶部数据环展示核心指标,中间快捷操作和训练推荐提供功能入口,底部周活动图表展示趋势。信息从"实时数据"到"快捷操作"到"历史趋势",按时间维度从近到远排列。
完整效果

数据层:WorkoutData

import { WEEKLY_DATA, WORKOUT_PLANS, ActivityRecord, WorkoutPlan } from '../services/WorkoutData'

两个数据源:

  • WEEKLY_DATA:一周的活动记录数组,每个元素是ActivityRecord(包含steps、calories、minutes、date等字段)
  • WORKOUT_PLANS:推荐训练计划数组,每个元素是WorkoutPlan(包含id、name、icon、duration、level、color等字段)

页面初始化时用WEEKLY_DATA[3]作为"今天"的数据——数组索引3代表周四(假设周一是索引0)。这是硬编码的,实际项目中应该用日期计算当前是周几,然后取对应索引。

@State today: ActivityRecord = WEEKLY_DATA[3]

@State today让页面响应式渲染——修改today的值,所有引用today的UI会自动更新。目前today是固定的,实际项目中应该从传感器或API获取实时数据。

整体布局

Stack({ alignContent: Alignment.Bottom }) { Scroll() { Column() { // Header // Stats rings // Quick actions // Recommended plans // Weekly chart Blank().height(80) } } // Bottom nav }

和购物商城一样的Stack结构:Scroll占据全屏,底部导航固定在Stack底部。Blank().height(80)留出导航栏高度的空间。

但有一个区别:这个页面没有用@State tab来控制底部导航的高亮——NavBtn的active参数是直接传入的布尔值(首页传true,其他传false)。和购物商城一样,Tab是页面级路由跳转,不需要tab状态变量。

Header:标题和目标进度

Row() { Column() { Text('运动健康').fontSize(26).fontWeight(FontWeight.Bold).fontColor(T1) Row() { Row() {}.width(6).height(6).borderRadius(3).backgroundColor('#00B894') Text(' 今日目标 · 已完成 85%').fontSize(12).fontColor(T3) }.margin({ top: 4 }) }.alignItems(HorizontalAlign.Start).layoutWeight(1) Stack() { Column() {}.width(42).height(42).borderRadius(21) .linearGradient({ angle: 135, colors: [[A, 0], ['#FF9F43', 1]] }) Text('🏃').fontSize(18) } }

Header由两部分组成:左侧标题+目标进度,右侧头像。

目标进度用绿色圆点+文字表示"已完成85%“。绿色(#00B894)在运动健康类App中是"达标"的通用色——和交通灯的绿灯一样,表示"通过”。85%是硬编码的,实际项目中应该根据today.steps/10000等数据动态计算。

头像是42×42的渐变圆形+跑步emoji。和购物商城的用户卡片头像实现方式一样——Stack叠加渐变底层+文字/emoji。这里用了emoji而不是首字母,因为运动健康App的头像更强调"运动感"而不是"用户身份"。

Stats rings:三环数据展示

Row() { this.RingStat('步数', this.today.steps.toString(), '/ 10000', A) this.RingStat('卡路里', this.today.calories.toString(), ' kcal', '#FF9F43') this.RingStat('运动', this.today.minutes.toString(), ' 分钟', '#5DADE2') }.width('100%').padding({ left: 16, right: 16 }) .justifyContent(FlexAlign.SpaceAround).margin({ bottom: 24 })

三个数据环横向排列,用justifyContent(FlexAlign.SpaceAround)均匀分布。

RingStat Builder

@Builder RingStat(label: string, value: string, unit: string, color: string) { Column() { Stack({ alignContent: Alignment.Center }) { Circle({ width: 80, height: 80 }).fill('#F5F5F7') Circle({ width: 70, height: 70 }).fill(color + '15') Column() { Text(value).fontSize(20).fontWeight(FontWeight.Bold).fontColor(color) Text(unit).fontSize(10).fontColor(T3) } } Text(label).fontSize(13).fontWeight(FontWeight.Medium).fontColor(T1).margin({ top: 8 }) } }

每个数据环由Stack叠加三层组成:

  1. 外圈:80×80的浅灰色圆形(#F5F5F7),作为"轨道"
  2. 内圈:70×70的半透明彩色圆形(color + ‘15’,约8%透明度),作为"进度指示"
  3. 数字:居中的数值+单位

目前内圈是固定大小的圆形,没有根据实际进度动态调整大小。真正的"环形进度条"需要用strokeWidthstrokeDasharray控制圆弧长度。这里简化成了两个同心圆——视觉上像"环",但没有进度指示功能。

三个颜色的含义:

  • 红色(A = #FF4757):步数——红色代表活力和能量
  • 橙色(#FF9F43):卡路里——橙色代表燃烧和热量
  • 蓝色(#5DADE2):运动时间——蓝色代表冷静和持久

Quick actions:快捷操作

Row({ space: 10 }) { this.QuickBtn('🎯', '开始运动', A, () => { router.pushUrl({ url: 'pages/WorkoutPage' }) }) this.QuickBtn('📊', '运动记录', '#5DADE2', () => { router.pushUrl({ url: 'pages/ProgressPage' }) }) this.QuickBtn('🍽️', '饮食记录', '#FF9F43', () => { router.pushUrl({ url: 'pages/MealPage' }) }) this.QuickBtn('👥', '社区', '#00B894', () => { router.pushUrl({ url: 'pages/CommunityPage' }) }) }.width('100%').padding({ left: 16, right: 16 }).margin({ bottom: 24 })

四个快捷按钮横向排列,每个按钮是彩色背景+emoji图标+文字标签。

QuickBtn Builder

@Builder QuickBtn(icon: string, label: string, color: string, action: () => void) { Column() { Text(icon).fontSize(24).margin({ bottom: 6 }) Text(label).fontSize(11).fontColor(T1) }.layoutWeight(1).padding({ top: 14, bottom: 14 }) .backgroundColor(color + '10').borderRadius(14) .onClick(action) }

layoutWeight(1)让四个按钮均分宽度。背景色用color + '10'——大约6%的透明度,非常淡的彩色底色。这种"浅色背景+深色文字"的组合比"彩色背景+白色文字"更柔和,适合信息密度高的仪表盘页面。

四个按钮对应四个功能入口:

  • 🎯 开始运动 → WorkoutPage(训练页面)
  • 📊 运动记录 → ProgressPage(进度页面)
  • 🍽️ 饮食记录 → MealPage(饮食页面)
  • 👥 社区 → CommunityPage(社区页面)

每个按钮的颜色和功能语义匹配:红色代表"开始"(行动力),蓝色代表"数据"(分析),橙色代表"食物"(温暖),绿色代表"社区"(生命力)。

Recommended plans:横向滚动训练卡片

Row() { Text('今日推荐训练').fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1) Blank() Text('全部 ›').fontSize(12).fontColor(A) }.width('100%').padding({ left: 16, right: 16 }).margin({ bottom: 12 }) Scroll() { Row({ space: 12 }) { ForEach(WORKOUT_PLANS, (plan: WorkoutPlan) => { Column() { Text(plan.icon).fontSize(36).margin({ bottom: 8 }) Text(plan.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(T1) Text(plan.duration + '分钟').fontSize(11).fontColor(T3) Row() { Text(plan.level).fontSize(10).fontColor(plan.color) }.padding({ left: 8, right: 8, top: 2, bottom: 2 }) .backgroundColor(plan.color + '15').borderRadius(6).margin({ top: 4 }) }.width(110).padding(14).backgroundColor(CW).borderRadius(16) .shadow({ radius: 6, color: 'rgba(0,0,0,0.03)', offsetY: 2 }) .onClick(() => { router.pushUrl({ url: 'pages/WorkoutPage', params: { 'planId': plan.id } }) }) }) }.width('100%').padding({ left: 16, right: 16 }) }.width('100%').scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off) .margin({ bottom: 24 })

训练卡片是横向滚动的Row,每个卡片固定110px宽。用Scroll组件包裹Row,设置scrollable(ScrollDirection.Horizontal)实现横向滚动。

卡片结构

每个训练卡片由四部分组成:

  1. emoji图标(36px)
  2. 训练名称(13px加粗)
  3. 持续时间(11px灰色)
  4. 难度标签(10px,颜色+半透明背景)

难度标签的背景色用plan.color + '15'——和RingStat的内圈一样,约8%透明度的彩色背景。标签文字颜色和背景色同色系,形成"同色深浅搭配"。

横向滚动的实现

Scroll() { Row({ space: 12 }) { ... } }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)

Scroll组件默认是纵向滚动。设置scrollable(ScrollDirection.Horizontal)后变成横向滚动。scrollBar(BarState.Off)隐藏滚动条——卡片区域通常不需要显示滚动条,用户能通过卡片的"截断"感知到"右边还有内容"。

外层Scroll和内层Row都需要设置宽度。Row的宽度是100%+padding,但实际上Row会被子元素撑开——三个110px的卡片+间距,总宽度超过屏幕宽度。Scroll会自动处理超出部分的滚动。

路由参数传递

.onClick(() => { router.pushUrl({ url: 'pages/WorkoutPage', params: { 'planId': plan.id } }) })

点击训练卡片跳转到WorkoutPage,传入planId参数。WorkoutPage可以根据planId加载对应的训练计划详情。这里用了和购物商城一样的Record<string,Object>类型断言。

Weekly chart:周活动柱状图

Text('本周活动').fontSize(17).fontWeight(FontWeight.Bold).fontColor(T1) .width('100%').padding({ left: 16 }).margin({ bottom: 12 }) Row() { ForEach(WEEKLY_DATA, (day: ActivityRecord) => { Column() { Column() {} .width(22).height(day.calories / 20).borderRadius(11) .linearGradient({ angle: 180, colors: [[A, 0], ['#FF9F43', 1]] }) Text(day.calories.toString()).fontSize(9).fontColor(T2).margin({ top: 4 }) Text(day.date).fontSize(9).fontColor(T3) }.alignItems(HorizontalAlign.Center).layoutWeight(1) }) }.width('100%').height(100).padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Bottom).margin({ bottom: 24 })

柱状图用ForEach渲染七天的数据,每天是一个Column(柱子+卡路里数值+日期)。

柱子的高度计算

.width(22).height(day.calories / 20).borderRadius(11)

柱子宽度固定22px,高度由day.calories / 20计算。比如卡路里是720,高度就是36px。除以20是一个缩放系数——把卡路里数值缩小到合理的像素范围。这个系数是根据最大卡路里值和柱状图区域高度(100px)反推出来的。

柱子用180°渐变(从上到下,红色到橙色),和数据环的颜色一致。borderRadius(11)是宽度的一半,让柱子顶部是半圆形。

对齐方式

.alignItems(VerticalAlign.Bottom)

Row的alignItems(VerticalAlign.Bottom)让所有柱子底部对齐——矮的柱子在上面,高的柱子在下面。这是柱状图的标准对齐方式——底部代表"零",高度代表"数值"。

如果用默认的居中对齐,矮柱子会居中显示,视觉上像"悬浮"在半空中,不符合柱状图的阅读习惯。

Bottom nav:底部导航

@Builder NavBtn(idx: number, label: string, active: boolean) { Column({ space: 2 }) { Text(label === '首页' ? '🏠' : (...)).fontSize(20) Text(label).fontSize(9).fontColor(active ? A : '#C0BFC6') }.onClick(() => { this.tab = idx if (idx === 1) router.pushUrl({ url: 'pages/WorkoutPage' }) // ... }) }

底部导航用emoji图标代替SymbolGlyph。和购物商城不同,这个项目没有用系统图标——可能是为了视觉风格统一(所有图标都是emoji)。

active参数控制高亮:active为true时文字变红色(A),为false时灰色(#C0BFC6)。但emoji图标没有根据active变色——emoji是彩色的,无法通过fontColor控制颜色。这是一个视觉上的不一致:文字会变色,但图标不会。

踩坑记录

WEEKLY_DATA[3]的硬编码。@State today = WEEKLY_DATA[3]假设今天是周四。实际项目中应该用日期计算:const dayOfWeek = new Date().getDay(); this.today = WEEKLY_DATA[dayOfWeek]。但鸿蒙的Date API和标准JavaScript有差异,需要注意时区问题。

RingStat没有真正的进度指示。两个同心圆只是视觉装饰,没有根据实际数据(比如15600/10000步)动态调整内圈大小。真正的环形进度条需要用Canvas绘制弧线,或者用多个Circle叠加+clip裁剪。这里为了简化用了静态圆形。

柱状图的高度系数。day.calories / 20的系数20是硬编码的。如果某天的卡路里特别高(比如2000),柱子高度就是100px,刚好填满区域。但如果卡路里超过2000,柱子会溢出。更好的做法是动态计算系数:const maxCalories = Math.max(...WEEKLY_DATA.map(d => d.calories)); const scale = 100 / maxCalories

emoji图标在不同设备上的渲染差异。底部导航的emoji(🏠🏋️📊👥👤)在不同鸿蒙设备上可能显示为不同样式——有些是彩色的,有些是黑白的。如果要上线,需要用SymbolGlyph或自定义图标替代。但原型阶段用emoji够用。

横向滚动的触摸区域。训练卡片的Scroll组件占据整个屏幕宽度,但触摸区域可能和底部导航重叠。用户在训练卡片区域滑动时,可能会误触底部导航。ArkUI的事件处理机制会优先处理最上层的组件,但实际上需要测试确认。


运动健康首页的核心挑战是"数据可视化"——把步数、卡路里、运动时间这些数字变成用户一眼能看懂的图形。数据环、柱状图、快捷操作卡片,每个模块都有自己的视觉语言。颜色的一致性很重要——红色贯穿步数、开始运动、登录按钮,形成品牌的视觉锚点。