训练页面是运动健康App的核心功能页。用户从首页点击"开始运动"或某个训练卡片跳转到这里,选择训练计划,浏览动作列表,然后开始训练。这个页面的任务是:展示可用的训练计划,展示每个计划包含的动作,让用户快速进入训练状态。
页面结构分为两部分:顶部的训练计划选择器(横向滚动)和下方的动作列表(纵向滚动)。两个滚动方向不同——计划选择器横向滚动节省垂直空间,动作列表纵向滚动承载更多内容。
完整效果
路由参数的接收
aboutToAppear(): void { const p = router.getParams() as Record<string, 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()返回的是Record<string, Object>类型,需要用as断言才能访问具体字段。如果路由参数中没有planId(用户直接打开WorkoutPage而不是从首页跳转),selectedPlan保持默认值0,planExercises保持空数组——页面显示全部动作而不是某个计划的动作。
训练计划选择器
横向滚动的Row
Scroll() { 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.5px | 1px |
| 边框颜色 | 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 Record<string,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)。训练计划卡片区域隐藏滚动条是合理的——用户能看到卡片被截断,知道"右边还有"。但动作列表隐藏滚动条可能不太好——用户不知道"下面还有多少动作"。更好的方案是:动作列表在滚动时显示滚动条,停止后隐藏。
训练页面的核心设计决策是"计划选择器横向滚动 + 动作列表纵向滚动"。横向滚动节省垂直空间,让用户在一屏内既能看到所有训练计划,又能看到部分动作列表。选中状态的视觉反馈(背景色+边框色变化)让用户清楚地知道"我选了哪个"。动作行的信息密度很高——图标、名称、肌群、难度、时长、卡路里——六个信息点在一行内展示,用户不需要点进去就能判断"这个动作适不适合我"。