HarmonyOS运动健康——首页仪表盘架构拆解
换了个方向做运动健康App。和购物商城的信息密度不同运动健康的核心是数据可视化——用户一眼看到今天走了多少步、消耗了多少卡路里、运动了多长时间。首页仪表盘的任务就是在一屏内展示这些数据同时提供快速入口。这个页面的布局逻辑是顶部数据环展示核心指标中间快捷操作和训练推荐提供功能入口底部周活动图表展示趋势。信息从实时数据到快捷操作到历史趋势按时间维度从近到远排列。完整效果数据层WorkoutDataimport { 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 BuilderBuilder 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叠加三层组成外圈80×80的浅灰色圆形#F5F5F7作为轨道内圈70×70的半透明彩色圆形color ‘15’约8%透明度作为进度指示数字居中的数值单位目前内圈是固定大小的圆形没有根据实际进度动态调整大小。真正的环形进度条需要用strokeWidth和strokeDasharray控制圆弧长度。这里简化成了两个同心圆——视觉上像环但没有进度指示功能。三个颜色的含义红色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 BuilderBuilder 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)实现横向滚动。卡片结构每个训练卡片由四部分组成emoji图标36px训练名称13px加粗持续时间11px灰色难度标签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加载对应的训练计划详情。这里用了和购物商城一样的Recordstring,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的事件处理机制会优先处理最上层的组件但实际上需要测试确认。运动健康首页的核心挑战是数据可视化——把步数、卡路里、运动时间这些数字变成用户一眼能看懂的图形。数据环、柱状图、快捷操作卡片每个模块都有自己的视觉语言。颜色的一致性很重要——红色贯穿步数、开始运动、登录按钮形成品牌的视觉锚点。