HarmonyOS运动健康——训练计划与动作库页面

发布时间:2026/7/10 23:27:30

HarmonyOS运动健康——训练计划与动作库页面 训练页面是运动健康App的核心功能页。用户从首页点击开始运动或某个训练卡片跳转到这里选择训练计划浏览动作列表然后开始训练。这个页面的任务是展示可用的训练计划展示每个计划包含的动作让用户快速进入训练状态。页面结构分为两部分顶部的训练计划选择器横向滚动和下方的动作列表纵向滚动。两个滚动方向不同——计划选择器横向滚动节省垂直空间动作列表纵向滚动承载更多内容。完整效果路由参数的接收aboutToAppear(): void { const p router.getParams() as Recordstring, Object if (p p[planId]) { this.selectedPlan p[planId] as number let plan: WorkoutPlan | undefined undefined for (let i: number 0; i WORKOUT_PLANS.length; i) { if (WORKOUT_PLANS[i].id this.selectedPlan) { plan WORKOUT_PLANS[i]; break } } if (plan) this.planExercises getExercisesForPlan(plan) } }从路由参数中读取planId然后在WORKOUT_PLANS数组中查找对应的训练计划。找到后调用getExercisesForPlan(plan)获取该计划包含的动作列表。这里没有用Array.find()而是用for循环手动遍历——可能是为了兼容性考虑ArkTS对Array方法的支持和标准TypeScript有差异。for循环是最保守的写法。router.getParams()返回的是Recordstring, Object类型需要用as断言才能访问具体字段。如果路由参数中没有planId用户直接打开WorkoutPage而不是从首页跳转selectedPlan保持默认值0planExercises保持空数组——页面显示全部动作而不是某个计划的动作。训练计划选择器横向滚动的RowScroll() { Row({ space: 12 }) { ForEach(WORKOUT_PLANS, (plan: WorkoutPlan) { Column() { Text(plan.icon).fontSize(32).margin({ bottom: 8 }) Text(plan.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(T1) Text(plan.duration 分钟).fontSize(10).fontColor(T3) }.width(100).padding(14) .backgroundColor(this.selectedPlan plan.id ? plan.color 15 : CW) .borderRadius(16) .border({ width: this.selectedPlan plan.id ? 1.5 : 1, color: this.selectedPlan plan.id ? plan.color : #F0EEF4 }) .onClick(() { this.selectedPlan plan.id; this.planExercises getExercisesForPlan(plan) }) }) }.width(100%).padding({ left: 16, right: 16 }) }.width(100%).scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)每个训练计划卡片固定100px宽包含emoji图标、名称、持续时间。选中状态和未选中状态有明显的视觉差异属性选中状态未选中状态背景色plan.color 15半透明彩色CW白色边框宽度1.5px1px边框颜色plan.color彩色#F0EEF4浅灰选中卡片的背景色和边框色都用训练计划自身的颜色——红色计划用红色蓝色计划用蓝色。这让用户能通过颜色快速识别我选了哪个计划。选中状态的交互逻辑.onClick(() { this.selectedPlan plan.id this.planExercises getExercisesForPlan(plan) })点击卡片做两件事更新selectedPlan触发UI重新渲染选中状态变化同时调用getExercisesForPlan(plan)更新动作列表。但这里有个问题planExercises虽然更新了但下方的动作列表渲染用的是EXERCISES全局动作数组不是planExercises。也就是说切换训练计划后动作列表没有变化。这可能是有意为之——展示所有动作让用户浏览全部可用动作。但也可能是bug——应该用planExercises渲染只显示当前计划包含的动作。如果是后者修复方案是把ForEach的数据源从EXERCISES改成this.planExercises。动作列表单个动作行的结构Row() { Text(ex.icon).fontSize(28).width(56).height(56).borderRadius(14) .backgroundColor(#F5F5F7).textAlign(TextAlign.Center) Column() { Text(ex.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1) Text(ex.muscles).fontSize(11).fontColor(T3).maxLines(1).margin({ top: 2 }) Row() { Text(ex.difficulty).fontSize(10).fontColor(A) .padding({ left: 6, right: 6, top: 1, bottom: 1 }) .backgroundColor(#FFF0F0).borderRadius(4) Text( ex.duration 分钟).fontSize(10).fontColor(T2) Text( · ex.calories 千卡).fontSize(10).fontColor(T2) }.margin({ top: 4 }) }.alignItems(HorizontalAlign.Start).margin({ left: 12 }).layoutWeight(1) SymbolGlyph($r(sys.symbol.chevron_right)).fontSize(14).fontColor([#DDD]) }每个动作行由三部分组成左侧图标、中间信息、右侧箭头。图标区域56×56的浅灰色圆角方块居中显示emoji。固定尺寸确保所有动作行的高度一致——不管emoji的实际渲染大小如何容器都是56×56。信息区域三行文字从上到下是动作名称、目标肌群、训练详情。alignItems(HorizontalAlign.Start)让三行文字左对齐layoutWeight(1)占据剩余空间。训练详情行由三个元素组成——难度标签、时长、卡路里。难度标签用红色文字浅红背景视觉上最醒目。时长和卡路里用灰色小字视觉层级较低。难度标签的样式Text(ex.difficulty).fontSize(10).fontColor(A) .padding({ left: 6, right: 6, top: 1, bottom: 1 }) .backgroundColor(#FFF0F0).borderRadius(4)难度标签是红色文字A #FF4757 浅红背景#FFF0F0 4px圆角。内边距很小上下1px左右6px让标签紧凑但不拥挤。所有难度标签都用红色——没有根据难度等级初级/中级/高级用不同颜色。如果要区分可以用初级绿色#00B894中级橙色#FF9F43高级红色#FF4757但目前的统一红色也能用——红色在运动健康App中代表挑战不管什么难度都是需要努力的意思。跳转到动作详情.onClick(() { router.pushUrl({ url: pages/ExerciseDetail, params: { id: ex.id } as Recordstring,Object }) })点击动作行跳转到ExerciseDetail页面传入动作ID。ExerciseDetail页面可以根据ID加载动作的详细信息步骤、注意事项、视频演示等。State 的数据流State selectedPlan: number 0 State planExercises: Exercise[] []两个State变量selectedPlan当前选中的训练计划ID控制计划选择器的高亮状态planExercises当前计划包含的动作列表理论上应该控制动作列表的渲染数据流是用户点击计划卡片 →selectedPlan更新 → UI重新渲染卡片高亮变化 →planExercises更新 → 动作列表应该变化但目前没变。如果要把planExercises真正用起来需要在ForEach中引用它ForEach(this.planExercises, (ex: Exercise) { ... })同时需要处理空状态——如果planExercises为空没有匹配的计划应该显示暂无动作的提示。嵌套滚动的处理页面有两个滚动区域外层的Scroll纵向包含整个页面内容和内层的Scroll横向包含训练计划卡片。Column() { // Header Scroll() { // 外层纵向滚动 Column() { Scroll() { // 内层横向滚动 Row() { ... } } // 动作列表 } } }嵌套滚动在移动端是常见需求但也是坑最多的布局模式。外层纵向滚动和内层横向滚动的触摸事件可能冲突——用户横向滑动计划卡片时外层可能会跟着纵向滚动。ArkUI的Scroll组件对嵌套滚动有一定的处理能力——内层Scroll会拦截横向触摸事件阻止外层Scroll响应。但在某些设备上这个拦截可能不完美。如果遇到滑计划卡片时页面跟着上下动的问题需要在内层Scroll上设置.gesture(PanGesture({ direction: PanDirection.Horizontal }))来强制只响应横向手势。踩坑记录EXERCISES和planExercises的混淆。动作列表的ForEach用了EXERCISES全局数组而不是this.planExercises当前计划的动作。如果用户从首页点击7分钟燃脂跳转过来期望看到的是7分钟燃脂计划包含的动作但实际看到的是所有动作。这是一个功能缺陷——切换计划后动作列表应该变化。for循环遍历数组。aboutToAppear中用for循环查找训练计划而不是Array.find()。这是因为ArkTS的Array方法支持可能不完整——在某些鸿蒙版本中find、filter等方法可能不可用或行为异常。for循环是最可靠的写法但代码量更多。训练详情行的拼接方式。 ex.duration 分钟和 · ex.calories 千卡用字符串拼接。前面的空格和中间点是为了分隔——难度标签、时长、卡路里三个元素之间需要视觉间隔。但这种在字符串里加空格的方式不够精确——空格宽度受字体影响。更好的做法是用Row margin控制间距。图标容器的textAlign。Text(ex.icon).textAlign(TextAlign.Center)让emoji在56×56的容器内居中。但emoji的渲染宽度不固定textAlign只能控制水平居中垂直居中需要靠容器的默认对齐Column的默认对齐是居中。如果emoji偏大或偏小视觉上可能不完全居中。滚动条隐藏的必要性。两个Scroll都设置了scrollBar(BarState.Off)。训练计划卡片区域隐藏滚动条是合理的——用户能看到卡片被截断知道右边还有。但动作列表隐藏滚动条可能不太好——用户不知道下面还有多少动作。更好的方案是动作列表在滚动时显示滚动条停止后隐藏。训练页面的核心设计决策是计划选择器横向滚动 动作列表纵向滚动。横向滚动节省垂直空间让用户在一屏内既能看到所有训练计划又能看到部分动作列表。选中状态的视觉反馈背景色边框色变化让用户清楚地知道我选了哪个。动作行的信息密度很高——图标、名称、肌群、难度、时长、卡路里——六个信息点在一行内展示用户不需要点进去就能判断这个动作适不适合我。

相关新闻