个人中心的六个菜单入口中,"身体数据"和"我的成就"是两个纯展示型页面。它们没有复杂的交互逻辑,但在布局方式上有明显差异——身体数据是纵向列表,成就页面是网格。这两个页面恰好代表了 ArkTS 里两种最常用的排列模式,值得拆开看看各自的实现细节。
完整效果
在这里插入图片描述
在这里插入图片描述

BodyDataPage:列表卡片的数据展示

页面结构

身体数据页面的视觉结构很简单:顶部导航栏,中间六个指标卡片纵向排列,每个卡片占据一行。从截图看,每张卡片左侧是指标名称(小字灰色)和数值(大字彩色),右侧是一个灰色的右箭头,暗示可以点击进入编辑。

整体布局用 Scroll > Column 实现:

Scroll() {
  Column() {
    this.DataCard('体重', '68.5', 'kg', A)
    this.DataCard('身高', '175', 'cm', '#5DADE2')
    this.DataCard('BMI', '22.4', '正常', '#00B894')
    this.DataCard('体脂率', '18.2', '%', '#FF9F43')
    this.DataCard('肌肉量', '52.3', 'kg', '#8A5DFA')
    this.DataCard('基础代谢', '1680', '千卡', '#E8734A')
    Blank().height(20)
  }.width('100%').padding({ left: 16, right: 16 })
}

在这里插入图片描述

Scroll 保证了内容超出屏幕时可以上下滑动,Column 里的六个 DataCard 依次纵向排列。最后的 Blank().height(20) 是底部留白,防止最后一张卡片紧贴屏幕边缘。

DataCard Builder 的拆解

@Builder DataCard(label: string, value: string, unit: string, color: string) {
  Row() {
    Column() {
      Text(label).fontSize(13).fontColor(T2)
      Row() {
        Text(value).fontSize(32).fontWeight(FontWeight.Bold).fontColor(color)
        Text(' ' + unit).fontSize(14).fontColor(T3)
      }.margin({ top: 4 })
    }.alignItems(HorizontalAlign.Start).layoutWeight(1)
    SymbolGlyph($r('sys.symbol.chevron_right')).fontSize(14).fontColor(['#DDD'])
  }.width('100%').padding(18).backgroundColor('#FFFFFF')
   .borderRadius(16).margin({ bottom: 10 })
}

在这里插入图片描述

逐层分析这个 Builder 的布局嵌套:

最外层 Row:横向排列,layoutWeight(1) 让左侧内容区占据所有剩余空间,右侧的箭头图标被挤到最右边。

中间 Column:纵向排列标签和数值,alignItems(HorizontalAlign.Start) 让文字左对齐。

数值行 Row:把数值和单位放在同一行。数值用 fontSize(32) 的大号加粗字体,单位用 fontSize(14) 的小号灰色字体。这个大小对比制造了"数值为主、单位为辅"的视觉层次。

右侧箭头SymbolGlyph($r('sys.symbol.chevron_right')) 是系统自带的右箭头符号。用 $r() 引用系统符号资源而不是硬编码路径,保证了在不同设备和系统版本上都能正确渲染。

颜色编码的设计意图

六个指标各有自己的颜色:

指标 颜色 色值
体重 红色 #FF4757
身高 蓝色 #5DADE2
BMI 绿色 #00B894
体脂率 橙色 #FF9F43
肌肉量 紫色 #8A5DFA
基础代谢 珊瑚色 #E8734A

这不是随意配色。红色代表"核心指标"(体重是最重要的身体数据),蓝色代表"基础属性"(身高不会变),绿色代表"健康状态"(BMI 正常是好事),橙色代表"需要关注"(体脂率偏高的话需要警惕),紫色和珊瑚色分别对应肌肉和代谢,和 App 的主色调体系保持一致。

这种"一个指标一个颜色"的做法在数据展示页面里很常见。好处是用户扫一眼就能通过颜色定位到自己关心的指标,不需要逐行阅读文字。坏处是如果指标太多(超过 7-8 个),颜色区分度会下降。目前 6 个指标正好在可区分的范围内。

"编辑"按钮的占位

右上角的红色"编辑"文字目前没有绑定任何点击事件。它是一个 UI 占位——告诉用户"这里以后可以编辑身体数据",但实际功能还没实现。

这种"先放占位,后填逻辑"的做法在 App 开发中很普遍。好处是用户能提前感知到页面的完整功能规划;坏处是如果占位时间太长,用户点了没反应会觉得 App 有 bug。在原型阶段可以接受,上线前必须处理。

AchievementPage:网格布局的成就展示

页面结构

成就页面的视觉和身体数据完全不同——它是两列网格,每个徽章占一个格子。从截图看,8 个徽章排列成 4 行 2 列,已获得的徽章有彩色标签,未获得的被压暗。

整体布局用 Scroll > Column > Grid > ForEach 实现:

Scroll() {
  Column() {
    Grid() {
      ForEach(this.badges, (b: Badge) => {
        GridItem() {
          // 徽章内容
        }
      })
    }.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)
     .width('100%').padding({ left: 16, right: 16 })
    Blank().height(20)
  }.width('100%')
}

Grid 是 ArkTS 的网格布局组件,columnsTemplate('1fr 1fr') 定义了两列等宽布局。1fr 是 fraction(分数)的缩写,两个 1fr 表示把可用空间平分成两份。columnsGap(10) 设置列间距为 10vp,rowsGap(10) 设置行间距为 10vp。

和身体数据页的 Column 纵向排列不同,Grid 天然支持多行多列,不需要嵌套 Row。这是选择 Grid 而不是 Row + Column 组合的核心原因。

Badge 接口与数据定义

interface Badge {
  icon: string
  name: string
  desc: string
  earned: boolean
  color: string
}

interface 定义徽章的数据结构。在 ArkTS 里 interfacetype 都能用来定义对象类型,但 interface 在 IDE 里的类型提示更完整——鼠标悬停时能看到每个字段的名称和类型,而 type 有时只显示 object

@State badges: Badge[] = [
  { icon: '🔥', name: '连续7天', desc: '连续运动7天', earned: true, color: '#FF4757' },
  { icon: '💯', name: '百次训练', desc: '完成100次训练', earned: true, color: '#FF9F43' },
  { icon: '🏃', name: '马拉松', desc: '累计跑步42公里', earned: true, color: '#5DADE2' },
  { icon: '⚡', name: '燃脂达人', desc: '累计消耗10000千卡', earned: true, color: '#FECA57' },
  { icon: '🎯', name: '30天挑战', desc: '连续运动30天', earned: false, color: '#8A5DFA' },
  { icon: '🏆', name: '年度冠军', desc: '年度运动时长第一', earned: false, color: '#E8734A' },
  { icon: '💪', name: '力量之王', desc: '完成500次力量训练', earned: false, color: '#00B894' },
  { icon: '🌟', name: '社区之星', desc: '获得1000个赞', earned: true, color: '#6C5CE7' },
]

8 个徽章,其中 5 个已获得(earned: true),3 个未获得。color 字段用于已获得徽章的标签背景色,每个徽章有自己的主题色。

GridItem 内部的条件渲染

这是成就页面最核心的实现细节——根据 earned 布尔值渲染完全不同的 UI:

GridItem() {
  Column() {
    Text(b.icon).fontSize(36).margin({ bottom: 8 })
     .opacity(b.earned ? 1 : 0.25)
    Text(b.name).fontSize(13).fontWeight(FontWeight.Bold)
     .fontColor(b.earned ? T1 : T3)
    Text(b.desc).fontSize(10).fontColor(T3).margin({ top: 2 })
    if (b.earned) {
      Text('✓ 已获得').fontSize(9).fontColor(b.color).fontWeight(FontWeight.Bold)
        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
        .backgroundColor(b.color + '15').borderRadius(6).margin({ top: 6 })
    } else {
      Row() {}.width(30).height(2).borderRadius(1)
       .backgroundColor('#E0DCE8').margin({ top: 8 })
    }
  }
}

三个关键的视觉差异:

图标透明度:已获得的徽章图标 opacity(1) 正常显示,未获得的 opacity(0.25) 几乎隐形。这让用户一眼就能区分哪些成就已经解锁。

名称颜色:已获得的用深色 T1(#1E1B2E),未获得的用灰色 T3(#BBBBBB)。颜色对比进一步强化了"已完成 vs 未完成"的区分。

底部标签:已获得的显示带颜色的"✓ 已获得"标签,未获得的只放一个小灰色横条。这个横条的作用是占位——如果两个格子一个有标签一个没有,底部会对不齐,视觉上会显得参差不齐。加一个等高的横条保持了网格的整齐。

已获得数量的动态计算

private earnedCount(): number {
  let c: number = 0
  for (let i: number = 0; i < this.badges.length; i++) {
    if (this.badges[i].earned) c++
  }
  return c
}

右上角显示的"5/8"就是用这个方法计算的。遍历 badges 数组,统计 earnedtrue 的数量。

没有用 filter().length 的写法,原因是 ArkTS 里 Array.filter() 返回的类型推断有时候不够精确,IDE 会报类型警告。手动 for 循环虽然啰嗦一点,但类型安全,不会有问题。

整卡片的透明度控制

.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(16).opacity(b.earned ? 1 : 0.6)

除了图标和文字的单独透明度控制,整个 GridItem 的容器也有透明度变化——未获得的卡片整体 opacity(0.6)。这种"双重透明度"(整体 + 局部)制造了一种"未解锁"的视觉效果,比单纯灰掉文字更自然。

两个页面的布局对比

身体数据页和成就页面在布局策略上的差异,本质上是"数据维度"决定的:

维度 身体数据页 成就页面
数据结构 扁平列表,每项独立 网格矩阵,每项等权
信息密度 一行一项,信息量大 一格一项,信息量小
排列方式 纵向 Column 二维 Grid
交互暗示 右箭头→可编辑 无箭头→纯展示
滚动方向 纵向 Scroll 纵向 Scroll(内容可能横向扩展)

身体数据用列表是因为每个指标有"标签 + 数值 + 单位"三层信息,需要水平空间来展开。成就用网格是因为每个徽章只有"图标 + 名称"两层信息,正方形格子刚好够放。

选错布局方式会导致视觉混乱。如果把身体数据塞进网格,每格里挤三个字段会显得拥挤;如果把成就排成列表,一行一个徽章会浪费大量横向空间,页面会变得很长。

页面导航的统一性

两个页面的导航栏结构完全一致:

Row() {
  Row() {
    SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(20).fontColor([T1])
  }.width(34).height(34).borderRadius(17)
   .backgroundColor('rgba(0,0,0,0.03)')
   .justifyContent(FlexAlign.Center)
   .onClick(() => { router.back() })
  Text('页面标题').fontSize(20).fontWeight(FontWeight.Bold)
   .fontColor(T1).margin({ left: 10 }).layoutWeight(1)
  // 右侧内容
}

34×34 的圆形返回按钮、rgba(0,0,0,0.03) 的浅灰背景、20px 的系统左箭头。这个返回按钮的样式从运动记录页、训练页、饮食记录页一路沿用下来,已经成为整个 App 的标准导航模式。

唯一差异是右侧区域:身体数据页放了红色"编辑"文字,成就页面放了"5/8"计数。这种"左侧固定、右侧灵活"的设计让导航栏在保持一致性的同时,能根据页面需求展示不同的辅助信息。

数据目前是 Mock 的

两个页面的数据目前都是写死在 @State 里的 mock 数据。身体数据的六个指标值、成就的八个徽章,都是硬编码的初始值。

如果以后要接真实数据,改造路径很清晰:

  • 身体数据页:从本地存储或服务器获取用户的身体指标,替换 @State 的初始值
  • 成就页面:根据用户的运动数据计算哪些成就已解锁,动态更新 earned 字段

但在当前原型阶段,mock 数据足够用来验证 UI 布局和视觉效果。先把界面做对,再把数据接通——这是大多数 App 开发的实际节奏。

Logo

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

更多推荐