HarmonyOS 6.0实战:音乐歌单我的页 —— 经验条与成就徽章墙的组合技

App 13「音乐歌单」我的 Tab(ProfileTab),是个人中心页。整页用渐变用户卡片(Lv.4 等级 + 74% 经验条 + 签到/数据报告)+ 4 栏彩色统计 + 本周使用条形图(周六高亮)+ 6 徽章成就墙(4 解锁 2 未解锁)+ 5 项菜单(带 NEW 角标)+ About 信息组成,是 ArkUI "个人中心"页的完整范本。本篇基于 13-music-playlist/entry/src/main/ets/pages/ProfileTab.ets(约 225 行)逐段拆解,附 4 张实机截图。

一、整体结构:6 个 @Builder 拼接

ProfileTab 整个组件用 6 个 @Builder 块拼接成完整的个人中心页:

build() {
  Column() {
    Scroll() {
      Column({ space: 0 }) {
        this.UserCard()
        this.StatRow()
        this.WeekChartCard()
        this.AchievementWall()
        this.MenuList()
        this.About()
      }
      .width('100%').padding({ bottom: D.pad + this.safeBottom + 20 })
    }
    .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
  }
  .width('100%').height('100%').backgroundColor(C.bg)
}

6 个块按"用户 → 数据 → 周图 → 成就 → 菜单 → 关于"的叙事顺序展开,与首页 HomeTab 的"头部 → 工具 → 数据 → 筛选 → 列表"不同,ProfileTab 是"人 → 数据 → 行为 → 资产 → 设置 → 关于"的纵向叙事。

整页用 Scroll 容器(与 HomeTab 一致),因为内容超过一屏,必须可滚。space: 0 让 UserCard 和 StatRow 紧贴——这是有意为之,UserCard 渐变 + StatRow 卡片 + margin({ top: -28 }) 让 StatRow "上浮"到 UserCard 底部,形成"卡片叠在用户卡上"的视觉效果(Material Design 风格的 elevation)。

padding({ bottom: D.pad + this.safeBottom + 20 }) 给 Scroll 内容底部留出 16vp + 安全区 + 20vp 缓冲,避免"关于"信息贴到 Tab 栏底部。

UserCard 是整页唯一一个不滚出视口始终可见的卡片(虽然它在 Scroll 内但因为最顶部),让用户随时看到自己的等级和经验。

音乐歌单我的页首屏 · 紫色渐变用户卡片+Lv.4+74%经验条+签到/数据报告

项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub

配图

二、UserCard:渐变用户卡 + Lv.4 徽章 + 经验条 + 签到/数据报告

UserCard 是整页的"个人门面",4 个信息层叠在紫色渐变上:头像 + Lv.4 徽章、姓名 + 认证标签、活跃天数 + 距下一级经验、签到按钮 + 数据报告按钮:

@Builder
UserCard() {
  Column({ space: 12 }) {
    Row({ space: 14 }) {
      Stack({ alignContent: Alignment.BottomEnd }) {
        Row() { Text('👤').fontSize(34) }
          .width(60).height(60).backgroundColor('rgba(255,255,255,0.35)').borderRadius(D.rLg).justifyContent(FlexAlign.Center)
          .border({ width: 2, color: 'rgba(255,255,255,0.7)' })
        Row() { Text('Lv.4').fontSize(9).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
          .padding({ left: 5, right: 5, top: 1, bottom: 1 })
          .backgroundColor(C.warn).borderRadius(4).border({ width: 1.5, color: '#FFFFFF' })
      }
      Column({ space: 5 }) {
        Row({ space: 8 }) {
          Text('小明同学').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text('认证').fontSize(9).fontColor('#FFFFFF')
            .backgroundColor('rgba(255,255,255,0.3)').padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(3)
        }
        Text('活跃用户 · 已使用 28 天').fontSize(11).fontColor('rgba(255,255,255,0.85)')
        Row({ space: 4 }) {
          Text('距 Lv.5 还需').fontSize(9).fontColor('rgba(255,255,255,0.7)')
          Text('120').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          Text('经验').fontSize(9).fontColor('rgba(255,255,255,0.7)')
        }
        Stack({ alignContent: Alignment.Start }) {
          Column().width('100%').height(4).backgroundColor('rgba(255,255,255,0.25)').borderRadius(2)
          Column().width('74%').height(4).backgroundColor('#FFFFFF').borderRadius(2)
        }.width(140)
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)
    }.width('100%').padding({ left: D.pad, right: D.pad, top: this.safeTop + 30, bottom: 16 })

    Row() {
      Row({ space: 5 }) {
        Text('🎁').fontSize(11)
        Text('每日签到').fontSize(11).fontColor('#FFFFFF')
      }
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .backgroundColor('rgba(255,255,255,0.2)').borderRadius(D.rSm)
      .onClick(() => { promptAction.showToast({ message: '签到成功 +5 经验' }); })
      Blank()
      Row({ space: 5 }) {
        Text('📊').fontSize(11)
        Text('数据报告').fontSize(11).fontColor('#FFFFFF')
      }
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .backgroundColor('rgba(255,255,255,0.2)').borderRadius(D.rSm)
      .onClick(() => { promptAction.showToast({ message: '数据报告' }); })
    }.width('100%').padding({ left: D.pad, right: D.pad, bottom: 14 })
  }
  .width('100%').linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] })
}

2.1 Stack 双层:头像 + Lv.4 徽章

Stack({ alignContent: Alignment.BottomEnd }) 让 Lv.4 标签"贴"在头像右下角:

  • 底层:60×60 头像块,半透明白色背景 (rgba(255,255,255,0.35)) + 2vp 白色描边
  • 上层:Lv.4 标签,橙底 (C.warn) + 白字 + 1.5vp 白边

StackalignContent(Alignment.BottomEnd) 让上层对齐"右+下",正好把 Lv.4 标签定位到头像的右下角,形成"角色等级徽章"的经典样式(游戏 App 常用)。

2.2 经验值 + 进度条

Stack({ alignContent: Alignment.Start }) {
  Column().width('100%').height(4).backgroundColor('rgba(255,255,255,0.25)').borderRadius(2)
  Column().width('74%').height(4).backgroundColor('#FFFFFF').borderRadius(2)
}.width(140)

Stack 进度条 = 背景 + 前景叠在同一位置:

  • 背景:100% 宽 4vp,半透明白色 (rgba(255,255,255,0.25))
  • 前景:74% 宽 4vp,纯白 (#FFFFFF)
  • 外层 .width(140) 限制进度条最大宽度 140vp

74% 表示"当前经验占 Lv.5 所需总经验的比例",120 经验 是"还差 120 经验升 Lv.5"。这两个数字(74% / 120)一起描述"成长进度",比单一百分比更清晰。

2.3 签到 + 数据报告双按钮

Row() 内左右各一个"伪按钮"(背景 + 文字 + emoji):

  • 每日签到(左):🎁 + 文字,半透明白底
  • 数据报告(右):📊 + 文字,半透明白底

中间 Blank() 撑开。两者都点击弹 Toast——"签到成功 +5 经验""数据报告""签到 +5 经验"是游戏化机制:用户每次签到都获得"经验值"反馈,激励每天打开 App。这是社区留存的核心机制

2.4 紫色渐变

.linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] })

与首页 HeroCard 同样的 135° 渐变(C.primary → C.accent)。UserCard 在页面最顶部,紫色延伸至状态栏背后(padding.top: this.safeTop + 30 让出安全区),形成顶部"沉浸式紫色块"。

三、StatRow:4 栏彩色统计数字

StatRow 是 4 个数字(历史订单 28 / 进行中 3 / 满意度 98% / 连续天数 15)横排,每个数字有自己的颜色:

@Builder
StatRow() {
  Row() {
    ForEach(this.stats, (s: StatItem, idx: number) => {
      Column({ space: 3 }) {
        Text(s.value).fontSize(18).fontWeight(FontWeight.Bold).fontColor(s.color)
        Text(s.label).fontSize(10).fontColor(C.textDim)
      }.layoutWeight(1)
      if (idx < this.stats.length - 1) { Divider().vertical(true).height(32).color(C.stroke) }
    }, (s: StatItem) => s.label)
  }
  .width('92%').height(76).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
  .justifyContent(FlexAlign.SpaceAround).alignItems(VerticalAlign.Center).margin({ top: -28 })
}

4 色映射

  • 历史订单 28 → C.primary
  • 进行中 3 → C.accent 浅紫
  • 满意度 98% → C.ok 绿
  • 连续天数 15 → C.warn

width('92%'):比父容器窄 8%,让卡片左右两侧各留 4% 空间——这是让 StatRow "缩进" 卡片内层,视觉上"嵌在 UserCard 下"。配合 margin({ top: -28 }),整张 StatRow 上浮 28vp,与 UserCard 底部重叠 28vp,形成"卡片叠在用户卡上"的悬浮效果。

alignItems(VerticalAlign.Center):让 4 个数字在 76vp 高度内垂直居中,比"靠顶"更平衡。

Divider().vertical(true).height(32) 是竖向分隔线,每两个数字之间用 1vp 浅灰竖线分隔。if (idx < this.stats.length - 1) 防止最后一条也有分隔线。

注意Divider().vertical(true) 的语法是 ArkUI 14+ 才支持的,垂直分隔线比手动 Column().width(1).height(32).backgroundColor(C.stroke) 更简洁。

四、WeekChartCard:本周使用条形图(周六高亮)

WeekChartCard 是"本周使用"的可视化条形图,与首页 WeekChartCard 类似但默认高亮周六(idx=5):

@Builder
WeekChartCard() {
  Column({ space: 12 }) {
    Row() {
      Text('📈 本周使用').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text)
      Blank()
      Text('20 次').fontSize(13).fontWeight(FontWeight.Bold).fontColor(C.primary)
    }.width('100%')

    Row({ space: 4 }) {
      ForEach(this.weekBars, (b: WeekBar, idx: number) => {
        Column({ space: 6 }) {
          Column()
            .width(18)
            .height(Math.max(6, b.value / 5 * 80))
            .borderRadius(4)
            .linearGradient({
              angle: 180,
              colors: idx === 5 ? [[C.primary, 0.0], [C.accent, 1.0]] : [[C.primarySoft, 0.0], [C.primary, 1.0]]
            })
          Text(b.day).fontSize(10).fontColor(idx === 5 ? C.primary : C.textDim)
            .fontWeight(idx === 5 ? FontWeight.Bold : FontWeight.Normal)
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
      }, (b: WeekBar) => b.day)
    }.width('100%')
  }
  .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
  .margin({ top: 14, left: D.pad, right: D.pad })
}

与首页 WeekChartCard 的差异

差异点首页 HomeTab我的 ProfileTab
标题📊 本周数据📈 本周使用
总数30 次20 次
数据3,5,2,6,4,7,32,3,1,4,3,5,2
最高柱(默认高亮)idx=4("五",value=4)idx=5("六",value=5)
高度计算b.value / 7 * 80b.value / 5 * 80
可点击是(onClick 切换高亮)否(静态)

b.value / 5 * 80 vs b.value / 7 * 80:我的页最大值是 5(周六),所以分母用 5,让周六柱顶到 80vp 满高。首页最大值是 7,分母用 7。两页都按"当前数据最大值"做归一化,让最高柱永远是 80vp,给视觉一致感。

周六高亮的隐喻:周六是周末/休息日,用户有更多时间使用 App,所以"本周使用峰值在周六"符合用户心理预期,让图表"讲一个用户能理解的故事"——这是数据可视化的高阶原则。

不可点击的差异:首页的 WeekChartCard 是"可交互"(点击切高亮),我的页的 WeekChartCard 是"纯展示"。差异源于"用户角色"——首页是"看数据"(想探索),我的页是"看自己"(确认状态)。同一组件在不同上下文用不同交互模式,是 ArkUI 声明式 UI 的灵活性体现。

五、AchievementWall:6 徽章成就墙

AchievementWall 是 6 个徽章横向排列的"成就墙",4 个解锁 + 2 个未解锁:

@Builder
AchievementWall() {
  Column({ space: 12 }) {
    Row() {
      Text('🏅 成就徽章').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text)
      Blank()
      Text(`${this.achievements.filter(a => a.unlocked).length}/${this.achievements.length}`).fontSize(11).fontColor(C.primary)
    }.width('100%')

    Row() {
      ForEach(this.achievements, (item: Achievement) => {
        Column({ space: 6 }) {
          Row() { Text(item.icon).fontSize(24) }
            .width(48).height(48).borderRadius(D.rMd).justifyContent(FlexAlign.Center)
            .backgroundColor(item.unlocked ? C.primarySoft : C.cardSoft)
            .opacity(item.unlocked ? 1 : 0.4)
          Text(item.title).fontSize(9).fontColor(item.unlocked ? C.text : C.textDim)
          Text(item.desc).fontSize(7).fontColor(C.textDim).maxLines(1)
        }.layoutWeight(1)
      }, (item: Achievement) => item.title)
    }.width('100%')
  }
  .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
  .margin({ top: 14, left: D.pad, right: D.pad })
}

"解锁/未解锁"双态实现 3 件套

  1. 图标块背景unlocked ? C.primarySoft : C.cardSoft — 解锁浅紫,未解浅灰
  2. 整体透明度opacity(unlocked ? 1 : 0.4) — 解锁 100%,未解 40%(关键!让"未解锁"有"灰蒙"感)
  3. 文字色unlocked ? C.text : C.textDim — 解锁深色,未解灰色

opacity: 0.4 是关键的"未解锁"标记——比单纯换背景色更直接传达"这个还没拿到"。

6 个徽章的数据编排

  • 🎯 初次使用(已解锁,第一次下单)
  • 🔥 活跃用户(已解锁,使用 7 天)
  • ⭐ 好评达人(已解锁,5 星好评 10 次)
  • 💰 省钱能手(已解锁,节省 100 元)
  • 👑 资深用户(未解锁,使用 100 天)
  • 💎 完美用户(未解锁,满意度 100%)

已解锁的 4 个都是"初级成就"(短期可达),未解锁的 2 个都是"高级成就"(长期挑战)。这种"近易远难"的成就编排是游戏化设计的关键——让用户一开始就能拿到几个徽章建立成就感,同时有"高级徽章"作为长期目标。

this.achievements.filter(a => a.unlocked).length 动态计数:右上角 "4/6" 通过 .filter() 实时计算已解锁数,数据更新时自动重渲染(因为 .filter 返回新数组,每次都重新求值)。

但这里没 @State achievementsachievementsprivate 不可变数据,所以 .filter() 只在初始化时算一次。如果未来要"解锁新成就后数字自动 +1",需要把 achievements 改为 @State,filter 才会响应式重算。

Text(item.desc).maxLines(1):描述文字("第一次下单")限制 1 行,超出省略号。maxLines(1) 是 ArkUI 的"行数限制"属性,避免长描述撑破卡片布局。

音乐歌单我的页下部 · 成就徽章墙+历史订单+消费统计菜单项

六、MenuList:5 项菜单(收藏带 NEW 角标)

MenuList 是 5 个菜单项的纵向列表,收藏项右上角带"NEW"绿色角标:

@Builder
MenuList() {
  Column() {
    ForEach(this.menus, (m: MenuItem, idx: number) => {
      Column() {
        Row({ space: 12 }) {
          Row() { Text(m.emoji).fontSize(20) }
            .width(36).height(36).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
          Column({ space: 2 }) {
            Text(m.title).fontSize(14).fontWeight(FontWeight.Medium).fontColor(C.text)
            Text(m.desc).fontSize(11).fontColor(C.textDim)
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          if (m.badge.length > 0) {
            Text(m.badge).fontSize(9).fontColor('#FFFFFF')
              .backgroundColor(m.badge === 'NEW' ? C.ok : C.danger)
              .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(D.rSm)
          }
          Text('›').fontSize(22).fontColor(C.textDim)
        }
        .width('100%').padding(14)
        .onClick(() => { promptAction.showToast({ message: m.title }); })
        if (idx < this.menus.length - 1) { Divider().color(C.stroke).margin({ left: 58 }) }
      }
    }, (m: MenuItem) => m.title)
  }
  .width('100%').backgroundColor(C.card).borderRadius(D.rMd).border({ width: 1, color: C.stroke })
  .margin({ top: 14, left: D.pad, right: D.pad }).clip(true)
}

几个细节

第一,if (m.badge.length > 0) 条件渲染m.badge 字段可能是空字符串 '''NEW',长度 > 0 时才显示角标。这种"空字符串作为'无角标'的占位"避免引入额外的 boolean 字段。

第二,badge === 'NEW' ? C.ok : C.danger:未来如果增加其他 badge(如 'HOT'),可设为红 (C.danger)。当前只有 'NEW' 一种,所以都是绿色。

第三,Divider().color(C.stroke).margin({ left: 58 }):每两个菜单项之间用 1vp 浅灰线分隔。margin.left: 58 让分隔线不贯穿整个卡片宽度,而是从"图标块+文字"的右侧开始——这是 iOS 设置项的经典样式(分隔线只覆盖"右侧部分",视觉上更精致)。

第四,.clip(true):让整张菜单卡的圆角 (D.rMd) 对内部 Divider 也生效——如果 Divider 比父容器宽,会被 clip 裁剪到圆角内。这是 ArkUI 里"父容器圆角 + 子内容裁剪"的标准做法。

5 个菜单项的编排:历史订单 / 消费统计 / 提醒设置 / 我的收藏 (NEW) / 设置——"按用户使用频次从高到低排序",收藏放第 4 位因为有 NEW 引导(用户最容易忽略的"次重要"功能靠角标引流)。

音乐歌单我的页中段 · 4栏统计+本周使用条形图+成就徽章墙

七、About:版本号 + 技术栈

@Builder
About() {
  Column({ space: 4 }) {
    Text('Version 1.0.0').fontSize(12).fontColor(C.textDim)
    Text('HarmonyOS 6.0+ · ArkTS · API 23').fontSize(10).fontColor(C.textDim)
  }
  .width('100%').padding({ top: 24, bottom: 10 }).alignItems(HorizontalAlign.Center)
}

Version 1.0.0HarmonyOS 6.0+ · ArkTS · API 23 是底部"关于"信息,10-12sp 灰色居中。

padding({ top: 24 }) 让 About 与上方 MenuList 拉开 24vp 间距,**"段间空隙大于段内空隙"**的视觉节奏(段间 24vp > 卡片间距 14vp)。alignItems(HorizontalAlign.Center) 让两行文字水平居中。

音乐歌单我的页底部 · 全部5项菜单+NEW角标+Version 1.0.0+About信息

八、@State 的克制

整页只有 0 个 @State——所有数据都是 private 字段(stats/menus/achievements/weekBars),且本页没有任何交互(WeekChartCard 不可点击、MenuList 点击只弹 Toast、AchievementWall 不可交互)。

@State 数量 = 0 反映了一个事实:个人中心页是"展示型"页面,主要功能是"展示用户信息",而非"接收用户操作"。这与 Func1Tab(5 个 @State 表单输入)和 Func2Tab(2 个 @State 筛选/搜索)形成对比。

如果未来加交互(如"切换主题色"开关、"签到按钮"更新经验值),就新增 @State。"先看是否需要交互,再决定是否加 @State" 是健康的状态管理习惯。

九、设计哲学:紫色品牌 + 个人身份

整页用紫色 (#8B5CF6) 主导:

  • UserCard 渐变紫底
  • StatRow 紫色数字(历史订单)
  • WeekChartCard 紫色渐变柱子
  • AchievementWall 浅紫底(已解锁徽章)
  • MenuList NEW 角标绿色(与紫色互补)

这种"主色 + 1 个辅色"的配色比"彩虹配色"高级得多——让整页"气质统一",不花哨。

个人中心的 4 个内容层

  1. 身份层(UserCard)— 我是谁、我的等级、我的活跃度
  2. 数据层(StatRow / WeekChartCard)— 我的使用数据
  3. 成就层(AchievementWall)— 我达成过什么
  4. 资产层(MenuList)— 我能做什么

4 层结构把"自我认知(身份)→ 行为回顾(数据)→ 行为激励(成就)→ 行动入口(资产)"的闭环讲清楚。这是个人中心页的通用骨架,适用于任何"个人主页"场景(音乐 App、阅读 App、运动 App、社交 App)。

十、可访问性与扩展

可访问性上:

  • 进度条配 "74%" 文字:色弱用户不靠颜色识别
  • 徽章配"已解锁/未解锁"opacity + 文字色:色弱用户能靠"颜色深浅"识别
  • 菜单项有"标题 + 副标题":屏幕阅读器能读出完整信息

扩展方向:

  • 签到按钮可以加 @State checked: boolean + @State exp: number,点击翻转 checked 并 exp + 5
  • 主题切换可以加 @State isDark: boolean,影响 Theme.ets
  • 成就解锁可以监听 exp 变化,达到阈值时把 achievements[i].unlocked = true
  • 菜单项点击应跳到对应页面(订单页 / 统计页 / 设置页),而非弹 Toast
Logo

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

更多推荐