鸿蒙原生实战:用 ArkUI 搭音乐歌单首页 —— 可点击条形图与推荐列表
鸿蒙原生实战:用 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:紫色渐变 + 沉浸式 + 三栏统计
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 = 0 和 selectedBar: 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 * 80。value 是 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 里实现"百分比进度条"的标准手法,不需要 LinearGradient 或 Progress 组件。
第三,进度条百分比文字双通道同步: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 一致的"轻反馈"策略——点击列表项弹出标题,提示用户"我收到了你的点击"。

七、@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:activeCat 和 selectedBar,分别控制分类条高亮和周数据图高亮。没有多余的 @State——stats/weekBars/items 都是 private 不可变数据,不需要响应式。
这是 ArkUI 状态管理的最佳实践:只把"会变的状态"放进 @State。如果把所有数据都声明 @State,会让 ArkUI 误以为它们都可能变,每次重渲染都做依赖追踪,性能下降。**"会变的只有交互态"**这条原则,让代码既简洁又高效。
@StorageProp('safeTop'/'safeBottom') 是另一种"读外部状态"的方式——AppStorage 是全局响应式存储,EntryAbility 在 onWindowStageCreate 里 setOrCreate 这两个 key,整个 App 任何组件都能用 @StorageProp 订阅。本页用 @StorageProp 而不是 @State,是因为安全区高度由系统决定,App 内任何组件都不应该修改它——@StorageProp 明确表达"这是只读订阅"语义。

九、沉浸式全屏 + 状态栏文字色
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接入真实userStatsAPI(替换硬编码 stats) - 给
WeekChartCard接入真实weekData(替换硬编码 weekBars) - 给
ItemList接入分页加载(.onReachEnd()触发加载下一页) - 把
HeroCard/WeekChartCard/ItemList抽成components/下独立组件,供多页复用
到此,App 13「音乐歌单」首页解析完毕。沉浸式紫色渐变 + 数据可视化 + 智能推荐三件套,配合 5 个 @Builder 拆分的工程化代码,让一个 demo 应用同时具备视觉冲击和工程参考价值。
更多推荐




所有评论(0)