鸿蒙原生实战:用 ArkUI 搭音乐歌单首页 —— 可点击条形图与推荐列表

App 13「音乐歌单」首页(HomeTab),主题色 #8B5CF6 紫色,4 个 Tab 分别为首页(🎧)、歌曲(🎵)、歌单(📋)、我的(👤)。首页采用沉浸式渐变 HeroCard + 三栏数字统计 + 快捷功能入口 + 本周数据条形图(点击切换高亮)+ 横向分类筛选 + 推荐列表(带 HOT/NEW/精选标签和进度条)。本篇基于源码 13-music-playlist/entry/src/main/ets/pages/HomeTab.ets(约 194 行)逐段拆解,附 4 张实机截图。

一、整体结构:Scroll 容器 + 5 个 @Builder 块

HomeTab 整个组件就是把 5 个 @Builder 拼接起来的"瀑布流",外层用 Scroll 容器装下所有内容,配合 safeBottom 在最底部留出 Tab 栏避让空间。代码骨架非常直白:

@Component
export struct HomeTab {
  @StorageProp('safeTop') safeTop: number = 0;
  @StorageProp('safeBottom') safeBottom: number = 0;
  @State activeCat: number = 0;
  @State selectedBar: number = 4;
  // ...
  build() {
    Column() {
      Scroll() {
        Column({ space: 14 }) {
          this.HeroCard()
          this.QuickActionRow()
          this.WeekChartCard()
          this.CategoryBar()
          this.ItemList()
          Blank().height(this.safeBottom + 20)
        }
        .width('100%').padding({ left: D.pad, right: D.pad })
      }
      .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
    }
    .width('100%').height('100%').backgroundColor(C.bg)
  }

5 个块按"头部 → 工具 → 数据 → 筛选 → 列表"五段叙事展开:HeroCard(情感化欢迎 + 3 个关键数据)→ QuickActionRow(4 个常用功能入口)→ WeekChartCard(本周数据可视化)→ CategoryBar(横向分类筛选)→ ItemList(推荐列表)。每个块独立成 @Builder,调试时可以单独看一个块渲染,单元测试和代码 review 都很友好。

Blank().height(this.safeBottom + 20) 是底部留白,让最后一张卡片不会贴到 Tab 栏上。safeBottom 来自 EntryAbility 注入到 AppStorage 的导航条高度,加 20vp 的视觉缓冲——这是沉浸式全屏的"补丁"标准写法。

scrollBar(BarState.Off) 关掉右侧的滚动条指示器,整页显得更干净;align(Alignment.Top) 让 Scroll 始终从顶部开始显示,不会因为内容不足而居中。

音乐歌单首页首屏 · 紫色渐变HeroCard+快捷入口+周数据

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

配图

二、HeroCard:紫色渐变 + 沉浸式 + 三栏统计

HeroCard 是首页的"门面",把安全区顶部、图标、标题、副标题、3 个关键数字塞进一张紫色渐变卡里:

@Builder
HeroCard() {
  Column({ space: 12 }) {
    Row({ space: 12 }) {
      Text('🏠').fontSize(24)
      Column({ space: 4 }) {
        Text('智能管理').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text('高效便捷 · 智能推荐').fontSize(12).fontColor('rgba(255,255,255,0.85)')
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)
    }.width('100%')

    Row() {
      ForEach(this.stats, (s: StatItem, idx: number) => {
        Column({ space: 2 }) {
          Text(s.value).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Text(s.label).fontSize(10).fontColor('rgba(255,255,255,0.8)')
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        if (idx < this.stats.length - 1) {
          Column().width(1).height(28).backgroundColor('rgba(255,255,255,0.25)')
        }
      }, (s: StatItem) => s.label)
    }.width('100%')
  }
  .width('100%').padding(18).padding({ top: this.safeTop + 20 })
  .borderRadius({ bottomLeft: D.rLg, bottomRight: D.rLg })
  .linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] })
}

几个关键设计点:

第一,padding({ top: this.safeTop + 20 }) 双重 padding 写法。先 padding(18) 给四面 18vp,再 padding({ top: this.safeTop + 20 }) 单独覆盖顶部为 safeTop + 20(状态栏高度 + 20vp 视觉缓冲)。这样既保证左右下三边距对称,又让顶部 padding 跟随状态栏高度自适应。比起 padding({ top: this.safeTop + 20, left: 18, right: 18, bottom: 18 }) 这种"四值全写"更简洁。

第二,borderRadius({ bottomLeft: D.rLg, bottomRight: D.rLg }) 圆角不对称。只圆角底部的两个角,让 HeroCard 顶部与状态栏无缝拼接(视觉上延伸到状态栏背后),底部则形成大圆角与下方白色卡片做软分隔。这是沉浸式 HeroCard 的标准手法:上不留白,下大圆角。如果四个角都圆角,顶部会出现一条空隙破坏沉浸感。

第三,linearGradient({ angle: 135, colors: [[C.primary, 0.0], [C.accent, 1.0]] }) 135° 斜向渐变C.primary (#8B5CF6 主紫) 到 C.accent (#A78BFA 浅紫) 从左上往右下渐变,营造"光从左上洒下"的视觉。135° 是大多数 App 渐变的默认角度,因为人眼习惯"光从左上"。

第四,三栏统计用 layoutWeight(1) 等分 + 间隔 Column().width(1) 竖线if (idx < this.stats.length - 1) 防止最后一条也加分隔线——这是分隔符的标准写法,硬编码 if-else 数组长度容易出错。rgba(255,255,255,0.25) 是 25% 不透明白色,在紫色背景上比纯白柔和得多。

stats 数据是硬编码的 [{ value: '128', label: '总数' }, { value: '32', label: '今日' }, { value: '86%', label: '完成率' }],三个数字组成"总量 / 增量 / 质量"三角,配合"智能管理 · 高效便捷 · 智能推荐"文案,把首页的定位(数据驱动 + 智能推荐)讲清楚。

三、QuickActionRow:4 个圆形入口的"工具栏"

QuickActionRow 把 4 个常用功能(搜索 / 收藏 / 统计 / 消息)压缩成一条横向入口带,是首页的"快捷导航":

@Builder
QuickActionRow() {
  Row() {
    ForEach(this.actions, (item: QuickAction) => {
      Column({ space: 6 }) {
        Row() { Text(item.icon).fontSize(22) }
          .width(44).height(44).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center)
        Text(item.title).fontSize(10).fontColor(C.textSub)
      }
      .layoutWeight(1)
      .onClick(() => { promptAction.showToast({ message: item.title }); })
    }, (item: QuickAction) => item.title)
  }
  .width('100%').padding(12).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke }).justifyContent(FlexAlign.SpaceAround)
}

每个入口由"44×44 圆角图标块 + 下方 10sp 文字"组成,4 个等宽分布。Row().width(44).height(44) 套一层是为了精确控制图标块的尺寸(不然 Text 直接设背景不会撑出 44 高度),borderRadius(D.rSm) 给图标块小圆角。.justifyContent(FlexAlign.SpaceAround) 让 4 个入口之间留出等距空白,整条横向带视觉均衡。

promptAction.showToast({ message: item.title }) 是个值得记住的小技巧——给任意点击事件一个"轻反馈"。如果直接 onClick 留空,用户的点击没有反应会以为应用卡了;用 Toast 立刻弹出"搜索""收藏"等文字,既给反馈又演示"这个按钮会跳到对应页面"。在原型/demo 阶段,这种"伪跳转"反馈能让应用感觉是完整的。

@State activeCat: number = 0selectedBar: number = 4 是整页唯二的可交互状态——activeCat 决定分类条哪个高亮,selectedBar 决定周数据图哪一根柱子高亮(默认 4 即"五")。

四、WeekChartCard:可点击的条形图与渐变映射

WeekChartCard 是首页的"数据可视化"模块,把一周 7 天的数据画成 7 根渐变条形图,点击柱子能切换高亮

@Builder
WeekChartCard() {
  Column({ space: 12 }) {
    Row() {
      Text('📊 本周数据').fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text)
      Blank()
      Text('30 次').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 / 7 * 80))
            .borderRadius(4)
            .linearGradient({
              angle: 180,
              colors: idx === this.selectedBar
                ? [[C.primary, 0.0], [C.accent, 1.0]]
                : [[C.primarySoft, 0.0], [C.primary, 1.0]]
            })
          Text(b.day).fontSize(10)
            .fontColor(idx === this.selectedBar ? C.primary : C.textDim)
            .fontWeight(idx === this.selectedBar ? FontWeight.Bold : FontWeight.Normal)
        }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        .onClick(() => { this.selectedBar = idx; })
      }, (b: WeekBar) => b.day)
    }.width('100%')
  }
  .width('100%').padding(14).backgroundColor(C.card).borderRadius(D.rMd)
  .border({ width: 1, color: C.stroke })
}

7 根柱子,每根 18vp 宽,柱子高度 = b.value / 7 * 80value 是 2-7 的整数(数据里最大值 7),所以最高柱 7/780=80vp,最矮柱 2/780≈22.9vp。Math.max(6, ...) 兜底最低高度 6vp,避免 value=0 时柱子消失。条形图最大高度 80vp 给视觉上限,整图高度约 80 + 6 + 10 = 96vp。

点击交互通过 onClick(() => { this.selectedBar = idx; }) 改变 selectedBar,触发整页 ArkUI 重渲染。柱子有两种渐变色:选中的柱子用 [C.primary, C.accent](主紫→浅紫的"激活"色),未选中的用 [C.primarySoft, C.primary](浅紫→主紫的"未激活"色)。180° 渐变(垂直方向)让"亮紫色在顶部,深紫色在底部"——视觉上像柱子"从亮处发出"。

文字 b.day(一/二/三/四/五/六/日)也跟随高亮变色:选中时主紫加粗,未选中时灰色常规。颜色 + 字重双通道标记选中态,比单靠颜色更易识别(色弱用户友好)。

右上角 30 次 是数据汇总(3+5+2+6+4+7+3 算下来 30),用户一眼看到"这周总共 30 次"——这是数据可视化的"summary 数字"惯例。

weekBars 数据故意让"五"(value=4) 成为默认选中(selectedBar = 4),并让"六"(value=7) 是最高柱——周五是工作日尾声,周六是峰值,对应"周五摸鱼、周六大干"的使用习惯。这种数据编排有故事性是 demo 设计的细节,真实项目里应根据真实数据排布。

五、CategoryBar:横向 Scroll 分类筛选

CategoryBar 是 4 个分类标签"全部 / 推荐 / 热门 / 最新"横向排列的筛选条:

@Builder
CategoryBar() {
  Scroll() {
    Row({ space: 8 }) {
      ForEach(this.cats, (cat: string, idx: number) => {
        Text(cat).fontSize(13)
          .fontColor(this.activeCat === idx ? '#FFFFFF' : C.textSub)
          .backgroundColor(this.activeCat === idx ? C.primary : C.card)
          .borderRadius(D.rSm).border({ width: 1, color: this.activeCat === idx ? C.primary : C.stroke })
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .onClick(() => { this.activeCat = idx; })
      }, (cat: string) => cat)
    }
  }
  .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')
}

横向 Scroll 容器装一行 Row 内的分类标签,4 个分类的宽度自动撑开(没有 layoutWeight,靠 padding 控制文字宽度)。ScrollDirection.Horizontal 开启横向滚动——4 个分类当前是装得下的,但 Scroll 包装让未来加到 8-10 个分类时不会破版。

每个分类是 Text + 背景 + 边框 + padding 复合体,选中态切换三种属性:背景(C.primary vs C.card)、文字色(白 vs 灰)、边框(紫色 vs 浅灰)。三处同步切换让"激活感"强烈。这种"选中态三件套:背景 + 文字 + 边框"是分类标签的标准做法。

onClick(() => { this.activeCat = idx; }) 是核心交互。当前 demo 没有联动 ItemList 的实际过滤(items 是写死的 3 条),真实项目应在点击时调用 this.items = filterByCategory(this.activeCat) 让列表实时筛选。

(cat: string) => cat 是 ForEach 的 keyGenerator,用分类名当 key 而不是下标。下标作为 key 在分类增删时会错位,用稳定字符串作为 key 才能保证 ArkUI 正确复用节点。activeCat = 1 时文本色变化靠 @State 自动重渲染,ForEach 不需要任何额外代码。

六、ItemList:带 HOT/NEW/精选标签 + 进度条的推荐列表

ItemList 是首页的"内容主体"——3 条推荐项目,每条都是"图标 + 标题 + 描述 + 标签 + 进度条"的复合卡:

@Builder
ItemList() {
  Column({ space: 10 }) {
    Row() {
      Text('推荐列表').fontSize(16).fontWeight(FontWeight.Bold).fontColor(C.text)
      Blank()
      Text('查看全部 ›').fontSize(11).fontColor(C.primary)
    }.width('100%')

    ForEach(this.items, (item: ListItem) => {
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Row() { Text(item.emoji).fontSize(28) }
            .width(56).height(56).backgroundColor(C.cardSoft).borderRadius(D.rMd).justifyContent(FlexAlign.Center)
          Column({ space: 4 }) {
            Row() {
              Text(item.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).layoutWeight(1)
              Text(item.tag).fontSize(9).fontColor('#FFFFFF')
                .backgroundColor(item.tag === 'HOT' ? C.danger : item.tag === 'NEW' ? C.ok : C.accent)
                .padding({ left: 5, right: 5, top: 2, bottom: 2 }).borderRadius(D.rSm)
            }.width('100%')
            Text(item.desc).fontSize(12).fontColor(C.textDim)
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        }.width('100%')

        Row() {
          Text('进度').fontSize(10).fontColor(C.textDim)
          Stack({ alignContent: Alignment.Start }) {
            Column().width('100%').height(5).backgroundColor(C.cardSoft).borderRadius(3)
            Column().width(`${item.progress}%`).height(5).borderRadius(3).backgroundColor(C.primary)
          }.layoutWeight(1).margin({ left: 8, right: 8 })
          Text(`${item.progress}%`).fontSize(10).fontColor(C.primary).fontWeight(FontWeight.Bold)
        }.width('100%')
      }
      .width('100%').padding(12).backgroundColor(C.card).borderRadius(D.rMd)
      .border({ width: 1, color: C.stroke })
      .onClick(() => { promptAction.showToast({ message: item.title }); })
    }, (item: ListItem) => item.id.toString())
  }.width('100%')
}

几个关键设计:

第一,标签颜色三态映射item.tag === 'HOT' ? C.danger : item.tag === 'NEW' ? C.ok : C.accent。Hazard(红)、新(绿)、精选(紫)三色对应不同语义。C.accent(浅紫)和卡片主题色保持一致,让"精选"标签不会显得突兀。

第二,Stack 进度条 = 背景条 + 前景条叠加。外层 Stack({ alignContent: Alignment.Start }) 把两条 Column 重叠对齐到左边。背景条 width('100%').height(5).backgroundColor(C.cardSoft) 是浅灰,前景条 width(${item.progress}%).height(5).backgroundColor(C.primary) 是紫色,用百分比字符串控制宽度——这是 ArkUI 里实现"百分比进度条"的标准手法,不需要 LinearGradientProgress 组件。

第三,进度条百分比文字双通道同步Text(${item.progress}%) 显示数字与 .width(${item.progress}%) 进度条宽度是同一个数据源 item.progress,自然同步。文字色 C.primary 加粗与进度条颜色一致,视觉统一。

第四,Stack 进度条 vs Row 进度条。如果用 Row,需要外层 Row 里塞两个 Column,第一个宽度 100%,第二个宽度 60%,然后靠 marginLeft 把第二条推到右边。Stack 更直接——两条 Column 重叠在 0 位置,前景条靠宽度自己"长"出来。Stack 的优势是天然支持任意宽度叠加,缺点是 Stack 本身有性能开销(要算 z 序),对 7-10 根进度条场景两者差不多,复杂列表推荐用 Progress 组件。

第五,条目 key 用 item.id.toString()ListItem 接口有 id: number,转字符串后传给 ForEach 第三个参数 keyGenerator。id 唯一稳定,未来增删/重排列表时 ArkUI 能正确复用节点不闪烁。

最后,onClick(() => promptAction.showToast(...)) 是和 QuickActionRow 一致的"轻反馈"策略——点击列表项弹出标题,提示用户"我收到了你的点击"。

音乐歌单首页下部 · 三条项目完整 + 底部Tab栏

七、@Builder 拆分的工程意义

整个 HomeTab 用 5 个 @Builder 拆开,每个 @Builder 独立可读。这种拆法有三重好处:

1. 单元渲染测试。DevEco Studio 的 Previewer 可以单独预览某个 @Builder,把 this.HeroCard() 替换为 HeroCard() 就能只渲染头部。在大型项目里,可以为每个 @Builder 写独立的 snapshot 测试。

2. 复用与重组。如果未来"个人成就页"也要 HeroCard,可以把 HeroCard 抽成独立组件 components/HeroCard.ets,被两个页面 import。@Builder 本身不导出,但可以提到 @Component struct HeroCard 形式支持跨文件复用。

3. 协作边界。多人协作时,"头部 HeroCard 由 A 负责、推荐列表由 B 负责",用 @Builder 划分后 git diff 干净,conflict 少。

但 @Builder 也有局限:不能接受泛型、不能写复杂逻辑、不能挂载生命周期。如果某个 @Builder 内有大量状态联动或异步逻辑,建议升级为独立 @Component 组件。

音乐歌单首页中段 · 推荐列表三条项目完整呈现

八、@State 状态管理的克制

整页只有 2 个 @State:activeCatselectedBar,分别控制分类条高亮和周数据图高亮。没有多余的 @State——stats/weekBars/items 都是 private 不可变数据,不需要响应式。

这是 ArkUI 状态管理的最佳实践:只把"会变的状态"放进 @State。如果把所有数据都声明 @State,会让 ArkUI 误以为它们都可能变,每次重渲染都做依赖追踪,性能下降。**"会变的只有交互态"**这条原则,让代码既简洁又高效。

@StorageProp('safeTop'/'safeBottom') 是另一种"读外部状态"的方式——AppStorage 是全局响应式存储,EntryAbility 在 onWindowStageCreate 里 setOrCreate 这两个 key,整个 App 任何组件都能用 @StorageProp 订阅。本页用 @StorageProp 而不是 @State,是因为安全区高度由系统决定,App 内任何组件都不应该修改它——@StorageProp 明确表达"这是只读订阅"语义。

音乐歌单首页底部 · 安全区留白 + 底部Tab栏

九、沉浸式全屏 + 状态栏文字色

EntryAbility.ets 里做了 3 件事实现沉浸式:

const win = windowStage.getMainWindowSync();
win.setWindowLayoutFullScreen(true);
win.setWindowSystemBarProperties({
  statusBarContentColor: '#1C2333',
  navigationBarContentColor: '#1C2333'
});
const top = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM);
const bottom = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR);
AppStorage.setOrCreate('safeTop', px2vp(top.topRect.height));
AppStorage.setOrCreate('safeBottom', px2vp(bottom.bottomRect.height));

第一行 setWindowLayoutFullScreen(true) 让内容延伸到状态栏和导航条背后——这是沉浸式的前提。第二行设置状态栏/导航条文字色为 #1C2333(深色),与首页 HeroCard 紫色形成对比。如果 HeroCard 是浅色头,状态栏文字就该深;如果是深色头,状态栏文字就该白。状态栏文字色跟随顶部底色是沉浸式适配的黄金规则。

getWindowAvoidArea 拿到的是像素值,需要 px2vp 转成 vp 才能在 ArkUI 布局里直接用。这两个值存到 AppStorage 后,全局任何组件都能通过 @StorageProp('safeTop') 订阅,是鸿蒙生态"系统能力 → 全局响应式状态"的范式。

十、配色与排版细节

整页用了 5 个颜色家族:紫色 C.primary (#8B5CF6) / C.accent (#A78BFA) / C.primarySoft (#F0EBFE) 撑起品牌色,文字 C.text (#1C2333) / C.textSub (#5A6483) / C.textDim (#9AA3BC) 三级灰度撑起信息层级,背景 C.bg (#F2F5FB) / C.card (#FFFFFF) / C.cardSoft (#F4F6FB) 撑起空间层级。一种主色 + 三级灰 + 三级背景是设计系统最经济的"7+1"配色框架。

D.rLg=20 / D.rMd=14 / D.rSm=10 三档圆角 + D.pad=16 标准 padding 间距,5 个块都是 D.rMd 圆角 + D.pad 间距,整页节奏统一。这种"少量 token + 大量复用"是设计系统建设的第一性原理。

space: 14(Scroll 内 Column 各块间距 14vp)+ space: 12(卡片内行间距)+ space: 6(小元素间)形成"14 → 12 → 6"三级节奏。所有数字都是 2 的倍数或 6 的倍数,视觉规整。

十一、可访问性与扩展建议

可访问性上,整页做到了"颜色 + 文字双通道":HOT 标签红色 + "HOT" 文字、进度条紫色 + "75%" 数字、选中分类紫色背景 + 文字加粗——色弱用户不靠颜色也能识别。但还可以更完善:

  • 进度条加 accessibilityHint,让屏幕阅读器读出"已完成 75%"
  • HeroCard 数字加 accessibilityLabel,避免读出"128 32 86%"等无语义串
  • ForEach 的 key 全部是稳定字段(id、day、title),未来增删/重排不会闪烁

扩展性上,整页是"瀑布流"骨架,未来可以:

  • HeroCard 接入真实 userStats API(替换硬编码 stats)
  • WeekChartCard 接入真实 weekData(替换硬编码 weekBars)
  • ItemList 接入分页加载(.onReachEnd() 触发加载下一页)
  • HeroCard/WeekChartCard/ItemList 抽成 components/ 下独立组件,供多页复用

到此,App 13「音乐歌单」首页解析完毕。沉浸式紫色渐变 + 数据可视化 + 智能推荐三件套,配合 5 个 @Builder 拆分的工程化代码,让一个 demo 应用同时具备视觉冲击和工程参考价值。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐