从训练页面点击一个动作,跳转到动作详情页。这个流程涉及两个页面的数据传递和交互衔接:WorkoutPage负责展示动作列表和训练计划选择,ExerciseDetail负责展示单个动作的完整信息。两个页面通过路由参数连接——WorkoutPage传入动作ID,ExerciseDetail根据ID加载数据。
完整效果
在这里插入图片描述

WorkoutPage:训练计划选择与动作列表

计划选择器的交互

训练计划选择器是横向滚动的Row,每个计划卡片固定100px宽。选中状态通过两个属性区分:背景色从白色变成半透明彩色,边框从1px浅灰变成1.5px彩色。

.backgroundColor(this.selectedPlan === plan.id ? plan.color + '15' : CW)
.border({ width: this.selectedPlan === plan.id ? 1.5 : 1,
  color: this.selectedPlan === plan.id ? plan.color : '#F0EEF4' })

在这里插入图片描述

点击卡片时做两件事:更新selectedPlan触发UI重渲染,同时调用getExercisesForPlan(plan)更新动作列表。但从截图来看,切换计划后动作列表没有变化——这可能是因为ForEach用的是EXERCISES(全局数组)而不是this.planExercises(当前计划的动作)。

动作列表的布局

每个动作行是Row结构:左侧56×56图标 + 中间三行信息 + 右侧箭头。

信息区的三行文字是:

  • 动作名称(14px加粗深色)——最醒目,用户第一眼看到
  • 目标肌群(11px灰色,maxLines:1)——次要信息,截断不影响理解
  • 训练详情行:红色难度标签 + 灰色时长 + 灰色卡路里——三个属性用文字拼接
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 })

在这里插入图片描述

难度标签用红色文字+浅红背景,视觉上最醒目。时长和卡路里用灰色小字,视觉层级较低。这种"难度突出、数据弱化"的设计让用户快速判断"这个动作难不难",而不是纠结具体数字。

点击跳转

.onClick(() => { router.pushUrl({ url: 'pages/ExerciseDetail', params: { 'id': ex.id } as Record<string,Object> }) })

在这里插入图片描述

点击动作行跳转到ExerciseDetail页面,传入id参数。as Record<string,Object>是类型断言——router.getParams()返回的是Record<string, Object>,需要断言才能访问具体字段。

从截图来看,训练计划页面有三个计划卡片(7分钟晨练、核心强化、力量突破),下方是六个动作(俯卧撑、深蹲、平板支撑、波比跳、哑铃弯举、仰卧起坐)。选中"核心强化"时卡片有绿色边框和浅绿背景。

ExerciseDetail:动作详情页

数据加载

@State ex: Exercise | undefined = undefined

aboutToAppear(): void {
  const p = router.getParams() as Record<string, Object>
  if (p && p['id']) this.ex = getExerciseById(p['id'] as number)
}

从路由参数中读取id,调用getExerciseById(id)获取动作数据。@State ex初始值是undefined——如果路由参数中没有id或者找不到对应的动作,页面不会渲染任何内容。

这里有一个防御性设计:if (this.ex)条件渲染整个页面内容。如果ex是undefined,页面只显示空的Column和背景色。不会崩溃,但用户看到的是空白页面——更好的做法是显示"动作不存在"的提示。

页面结构

Column() {
  // Header(返回按钮+标题)
  if (this.ex) {
    Scroll() {
      Column() {
        // 图标区
        // 标签行
        // 目标肌群
        // 动作步骤
      }
    }
  }
}

和WorkoutPage一样的Header结构:左侧返回按钮+右侧标题。但ExerciseDetail没有底部导航栏——它是子页面,用户看完详情后返回WorkoutPage,不需要底部导航。

图标区

Text(this.ex.icon).fontSize(80).width('100%').height(160).textAlign(TextAlign.Center)
  .backgroundColor('#F5F5F7').borderRadius(20)

80px的emoji图标放在160px高的浅灰色区域内。textAlign(TextAlign.Center)让emoji水平居中,Column的默认垂直居中让emoji垂直居中。borderRadius(20)让灰色区域有圆角。

这个区域占据了屏幕上半部分的大量空间——目的是让用户一眼看到"这是什么动作"。emoji在不同设备上的渲染效果不同,但80px的大尺寸能保证辨识度。

标签行

Row() {
  this.Tag('⏱ ' + this.ex.duration + '分钟', A)
  this.Tag('🔥 ' + this.ex.calories + '千卡', '#FF9F43')
  this.Tag('📊 ' + this.ex.difficulty, '#5DADE2')
}.width('100%').margin({ top: 16, bottom: 12 }).justifyContent(FlexAlign.Start)

三个标签横向排列,分别用红、橙、蓝色。每个标签是emoji+文字的组合,和训练计划页面的难度标签类似但更大(fontSize: 12, padding: 10×5)。

Tag Builder

@Builder Tag(text: string, color: string) {
  Text(text).fontSize(12).fontColor(color).padding({ left: 10, right: 10, top: 5, bottom: 5 })
    .backgroundColor(color + '12').borderRadius(8).margin({ right: 8 })
}

Tag Builder接收文本和颜色,生成一个带圆角背景的标签。color + '12'是约7%透明度的彩色背景,margin({ right: 8 })让标签之间有间距。

这个Builder的设计和WorkoutPage的难度标签思路一致——用color + 十六进制透明度做背景色。但ExerciseDetail的标签更大(padding 10×5 vs 6×1),因为详情页有更多空间。

目标肌群

Text('目标肌群').fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1).width('100%').margin({ bottom: 8 })
Text(this.ex.muscles).fontSize(14).fontColor(T2)

两行文字:标题(16px加粗)+ 内容(14px灰色)。标题用width('100%')确保占满整行,内容紧跟其后。

从截图来看,“目标肌群"显示为"胸肌·肱三头肌·核心”——用中文中间点分隔多个肌群。这是WorkoutData中定义的数据格式,详情页直接展示。

动作步骤

Text('动作步骤').fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1).width('100%').margin({ top: 16, bottom: 10 })
ForEach(this.ex.steps, (step: string, idx: number) => {
  Row() {
    Column() { Text((idx+1).toString()).fontSize(12).fontWeight(FontWeight.Bold).fontColor(Color.White) }
    .width(24).height(24).borderRadius(12).backgroundColor(A)
    .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
    Text(step).fontSize(14).fontColor(T1).margin({ left: 12 }).layoutWeight(1)
  }.width('100%').padding(12).backgroundColor('#FFFFFF').borderRadius(12).margin({ bottom: 8 })
})

每个步骤由两部分组成:左侧红色圆形编号 + 右侧步骤文字。

红色圆形编号: 24×24的红色圆形,居中显示白色数字。borderRadius(12)是宽度的一半,确保是正圆。justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)让数字在圆形内居中。

步骤文字: 14px深色文字,margin({ left: 12 })和编号保持间距,layoutWeight(1)占据剩余空间。

步骤卡片: 每个步骤是白色背景+12px圆角+12px内边距。步骤之间用margin({ bottom: 8 })分隔。

从截图来看,俯卧撑有四个步骤:

  1. 双手撑地与肩同宽
  2. 身体保持直线
  3. 屈肘下降至胸部接近地面
  4. 推起回到起始位置

每个步骤卡片独立,视觉上像"任务清单"——用户可以逐个步骤阅读,完成后在心里打勾。

两个页面的数据流

首页 → [点击训练卡片] → WorkoutPage → [点击动作行] → ExerciseDetail
         传入planId          传入id

数据流是单向的:首页传planId给WorkoutPage,WorkoutPage传id给ExerciseDetail。每个页面只接收自己需要的数据,不依赖上上个页面的状态。

WorkoutPage的aboutToAppear接收planId后设置selectedPlan,WorkoutData根据planId返回对应的训练计划。ExerciseDetail的aboutToAppear接收id后调用getExerciseById(id)获取动作详情。

这种"逐级传递"的模式比"全局状态"更简单——不需要AppStorage或PersistentStorage,每个页面只关心自己的路由参数。缺点是如果需要跨页面共享数据(比如"用户正在训练的动作"),就需要额外的状态管理方案。

踩坑记录

ex的undefined处理。 @State ex: Exercise | undefined = undefined初始值是undefined,aboutToAppear中如果找不到对应的动作,ex保持undefined。页面的if (this.ex)条件渲染避免了崩溃,但用户看到的是空白页面。更好的做法是显示一个"动作不存在"的提示,或者用router.back()返回上一页。

emoji在不同设备的渲染差异。 标签行用emoji(⏱🔥📊)代替图标。emoji在不同鸿蒙设备上可能显示为不同样式——有些是彩色的,有些是黑白的。如果要上线,需要用SymbolGlyph或自定义图标替代。

步骤编号的居中实现。 红色圆形编号用justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)实现居中。Column的默认方向是垂直的,justifyContent控制垂直居中,alignItems控制水平居中。如果用Row而不是Column,居中逻辑会反过来。

训练详情行的字符串拼接。 WorkoutPage中动作列表的训练详情行用字符串拼接:' ' + ex.duration + '分钟'' · ' + ex.calories + '千卡'。前面的空格和中间点是分隔符,但空格宽度受字体影响,不够精确。ExerciseDetail的标签用独立的Tag Builder,每个标签有自己的padding和margin,间距更可控。

滚动区域的高度计算。 ExerciseDetail的Scroll占据layoutWeight(1)——扣除Header后的全部空间。Scroll内部的Column没有固定高度,会根据内容自动撑开。如果内容超过屏幕高度,Scroll会处理滚动。但如果内容太少(比如只有两三个步骤),底部会有大量空白。Blank().height(30)在底部加了30px的间距,避免最后一个步骤卡片紧贴屏幕底部。


训练页面到动作详情的跳转是运动健康App的核心交互路径。WorkoutPage负责"选择"(选计划、选动作),ExerciseDetail负责"展示"(展示完整信息)。两个页面通过路由参数连接,数据单向流动,各自只关心自己的职责。这种"选择→详情"的模式在几乎所有App中都存在——电商的商品列表→商品详情、新闻的文章列表→文章详情——核心逻辑是一样的:列表页承载浏览和筛选,详情页承载深度阅读和操作。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐