当前位置: 首页 > news >正文

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

训练页面是运动健康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.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 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方法支持可能不完整——在某些鸿蒙版本中,findfilter等方法可能不可用或行为异常。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)。训练计划卡片区域隐藏滚动条是合理的——用户能看到卡片被截断,知道"右边还有"。但动作列表隐藏滚动条可能不太好——用户不知道"下面还有多少动作"。更好的方案是:动作列表在滚动时显示滚动条,停止后隐藏。


训练页面的核心设计决策是"计划选择器横向滚动 + 动作列表纵向滚动"。横向滚动节省垂直空间,让用户在一屏内既能看到所有训练计划,又能看到部分动作列表。选中状态的视觉反馈(背景色+边框色变化)让用户清楚地知道"我选了哪个"。动作行的信息密度很高——图标、名称、肌群、难度、时长、卡路里——六个信息点在一行内展示,用户不需要点进去就能判断"这个动作适不适合我"。

http://www.jsqmd.com/news/1163812/

相关文章:

  • Claude 复制内容带乱码太头疼?AI 导出鸭一键解决复制导出难题,程序员高效必备工具
  • 2026南京江宁免砸砖防水口碑实测,16个真实案例:卫生间漏水不砸砖到底行不行? - 苏易房屋修缮
  • 【RT-DETR涨点改进】25 从“能跑”到“跑得稳”:用C++封装RT-DETR推理SDK的实战心法
  • 基于Sentinel2的荒漠化指数计算
  • 前端链路追踪:用 OpenTelemetry 打通浏览器到服务端的性能数据
  • 2026年7月最新深圳真力时官方售后热线及客户服务网点地址 - 亨得利官方服务中心
  • 关于玻璃自爆
  • 复杂任务的 Spec 怎么写:从提示词到工程资产
  • 戏韵华章·正青春——央视戏曲频道焕彩出彩
  • 2026广州越秀区家庭搬家完整攻略,打包耗材选购、车型人工搭配与搬家总价计算方式 - gzdjxd
  • 高质量数据争夺战:大模型时代的下一个护城河
  • 这个业务逻辑表面上看起来是一个数据解析和结构化(打包成“簇”,即 C# 中的结构体或类)的过程
  • Axure动态滑块进度器设计指南:打造高效交互体验
  • 2026六安市金安区生日宴特色实测|5家热门门店 - 资讯纵览
  • Bun又用Rust重写:借助AI对抗工作流11天改写50万行
  • 可白嫖源码---课程设计--毕业设计--springboot基于王者荣耀比赛校园选拔赛平台[编号:project23417] (案例分析)
  • 大模型流式对话界面:从 SSE 管道到打字机渲染的前端架构设计
  • AI 驱动的组件文档生成:从 JSDoc 注释到完整 Storybook 示例的自动化
  • 【2013-11-18】Android应用开发笔记:tabhost的两种实现方式
  • 无货源自动下单安全吗?新手先确认这 5 件事,抖掌柜密文履约更合规 - 抖掌柜
  • 2026年拓客软件排行榜TOP10:功能、价格、适用人群全解析 - 米諾
  • UVa 641 Do the Untwist
  • 【2013-11-18】Android应用开发笔记:swipelistview介绍
  • 文心输出的内容粘贴有符号好烦不用愁,AI 导出鸭轻松规整格式
  • 为了让代码极具可读性、结构清晰,并且彻底摆脱各种复杂的底层字符编码,我们应该引入 C# 现代开发中最推崇的面向对象与表现力原则
  • 2026湖北真空武昌鱼货源新选择:荆味、樊口厂家产地直供详解
  • 2026 年 7 月苏州吴中特色餐饮店测评:地道江南风味探店指南 - 资讯纵览
  • 终极指南:如何免费重置JetBrains IDE试用期,轻松获得无限30天使用权限
  • APScheduler 是什么?
  • AI 能记住了,但能自己干活吗?——看懂执行系统,你就知道它怎么完成复杂任务