UI 美化方案:仿 Apple Music (iOS)
周期:约 2 周
前置:Phase 2 基础功能完成(已有音乐库浏览、播放器、MiniPlayer)
目标:在保留 Material3 基础组件的前提下,通过自定义组件、动效和视觉语言,让 app 观感接近 Apple Music iOS
设计原则
- 内容优先:封面图片是视觉核心,其他元素(文字、按钮)都为封面服务
- 大留白 + 大圆角:间距宽松(1624dp),圆角偏大(1224dp)
- 弹性动效:所有过渡使用 spring,避免线性/匀速动画
- 动态取色:基于当前歌曲封面提取主色调,驱动渐变和强调色
- 底部 Tab Bar:符合 iOS 导航习惯,同时对 Android 大屏幕单手操作也更友好
- 分组圆角卡片列表:替代 Material 传统 ListItem 的分隔线风格
里程碑验收标准
- [ ] 底部 Tab Bar 替换当前的顶部 TabRow
- [ ] NowPlayingScreen 使用动态渐变背景,颜色从当前封面提取
- [ ] MiniPlayer → NowPlaying 使用共享元素转场(封面平滑放大)
- [ ] 所有列表使用圆角卡片风格,统一 elevation / shadow 语言
- [ ] 大标题 TopBar(LargeTopAppBar),滚动时平滑过渡到小标题
- [ ] Spring 动效替换所有默认线性动画
- [ ] 封面加载支持占位图渐入,列表滚动流畅无掉帧
- [ ] 浅色/深色主题都通过封面动态取色调整
依赖新增
gradle/libs.versions.toml
1 | [versions] |
app/build.gradle.kts
1 | // Palette - 从封面提取主色 |
说明:
androidx.palette:Google 官方库,从 Bitmap 提取 dominant / vibrant / muted 等主色。唯一必需的新依赖- Haze 暂缓:原计划用 Chris Banes 的 Haze 库做毛玻璃 TabBar,但本方案"不在范围内"一节已经把毛玻璃 TabBar 列为第一版不做。先别引入依赖,后续真要做再加。执行时请自行查询 Haze 最新版(作者迭代较快)而不是照抄本文档里的版本号
第 1 周:骨架重构
1.1 底部 TabBar 替换顶部 TabRow
目标:把 LibraryScreen 里顶部 4 个 Tab(歌曲 / 专辑 / 艺术家 / 文件夹)搬到屏幕底部,作为全局导航的一部分。
改动文件:
ui/navigation/AppNavigation.kt— 顶层增加NavigationBarui/screen/LibraryScreen.kt— 可整个废弃。原有的 tab 切换逻辑(选中 Tab index)由导航本身承担- 直接复用已有的
SongListScreen/AlbumListScreen/ArtistListScreen/FolderBrowserScreen,每个注册成独立路由即可,不需要新建文件
重要:LibraryViewModel 原本承担"Tab index 状态 + 初次扫描触发"两件事。Tab index 现在由导航接管,但初次扫描的触发逻辑必须保留。建议:
- 把
initLibrary()/reflashLibrary()/scanProgress迁移到一个LibraryScannerViewModel(用@HiltViewModel+hiltViewModel(navBackStackEntry),以 navGraph scope 共享给 4 个 tab 页) - 或者更简单:在
MainActivity/AppNavigation最外层 DisposableEffect 里触发一次扫描
架构调整:
1 | 当前: |
底部栏伪代码:
1 |
|
要点:
- 必须直接
val currentRoute = ...?.destination?.route,不要嵌derivedStateOf,前者 State 本身会触发重组,用derivedStateOf每次都新建 state 反而不生效 - Tab 切换要用
popUpTo + saveState + restoreState组合,否则切 Tab 会堆积在回退栈里 - 用
AnimatedVisibility让 TabBar 滑入滑出,避免硬切带来的跳变
1.2 LargeTopAppBar + 大标题滚动联动
目标:每个 tab 页顶部显示大标题(类似 iOS "歌曲"两个大字),列表向上滚时大标题平滑缩小成普通 TopAppBar 标题。
改动文件:
ui/screen/SongListScreen.ktui/screen/AlbumListScreen.ktui/screen/ArtistListScreen.ktui/screen/FolderBrowserScreen.kt
伪代码:
1 |
|
要点:
exitUntilCollapsedScrollBehavior滚动时大标题先缩小再贴到顶部- 透明 container color 让下面的内容能"透过" TopBar 背景,配合后续的渐变背景
1.3 分组圆角列表卡片样式
目标:列表项从"ListItem + 分隔线"改成"Surface + 圆角 + 淡阴影"的卡片风格,类似 Apple Music 歌曲列表的那种"每一首歌独立一张小卡"。
方案 A:每个 item 单独一张卡片(iOS Apple Music 的现代做法)
方案 B:整组 item 共享一张大卡片,内部用分隔线(iOS 设置页的做法)
推荐 A 对歌曲列表,B 对专辑详情页的曲目列表。
新建 ui/component/MusicListItem.kt:
1 |
|
然后 SongListItem 改用这个组件:
1 |
|
第 2 周:动效 + NowPlaying 沉浸式
2.1 封面主色提取工具
新建 ui/theme/PaletteExtractor.kt:
1 | package io.kyonqi.musicplayer.ui.theme |
注意:之前版本用 asDrawable().draw(canvas) 再手动转 Bitmap,多此一举且遇到 intrinsicWidth == -1 的封面会崩。AudioCoverFetcher 返回的就是 BitmapImage,直接 cast 取 bitmap 最干净。
2.2 NowPlayingScreen 沉浸式重构
目标:
- 整屏背景用当前封面的主色做径向/线性渐变
- 封面大图带轻微缩放动效(正在播放时微妙放大 1.02x,类似 Apple Music)
- 歌曲标题 + 艺术家 + 进度条 + 按钮布局更紧凑,间距统一
改动文件:ui/screen/NowPlayingScreen.kt
关键片段:
1 |
|
要点:
- 背景渐变:
Brush.verticalGradient用动态颜色 animateColorAsState切歌时颜色平滑过渡animateFloatAsState+spring做封面缩放(dampingRatio 0.6 是经典弹性感)- 所有文字用
Color.White(因为背景是深色),或根据dominant.luminance()自适应
2.3 MiniPlayer → NowPlaying 的展开转场
重要澄清:iOS Apple Music 实际上不是用"共享元素转场"实现 MiniPlayer → NowPlaying。它是一个从底部滑上来的 sheet,盖住整个屏幕(包括 TabBar),MiniPlayer 所在位置就变成 NowPlaying 的一部分。Compose 里用 SharedTransitionLayout 模拟这个交互会遇到架构障碍 —— MiniPlayer 在 Scaffold.bottomBar 中,不处于任何 NavHost composable 的 AnimatedVisibilityScope 内,而 sharedElement 必需两端都有 AnimatedVisibilityScope。
推荐方案:用 BottomSheetScaffold 重构,不走 NavHost 的 now_playing 路由。
架构改动:
NowPlayingScreen不再注册成 NavHost 路由,改为一个BottomSheetScaffold的 sheet 内容MiniPlayer点击时,sheetState展开到Expanded(peek 高度 = MiniPlayer 高度,展开高度 = 全屏)- MiniPlayer 是 sheet 内部在 collapsed 状态下显示的内容,展开时淡出、NowPlaying UI 淡入,天然的共享位置,不需要 shared element
- 用户可以下滑 sheet 关掉(符合 iOS 交互),也可以点返回键
伪代码(外层 scaffold):
1 |
|
ExpandableNowPlayingContent 内部根据 progress 插值:
1 |
|
关键 API:
progressToExpanded()是自定义扩展(不是官方),读SheetState内部offset计算 0…1 progress- 或用
BottomSheetState.offset手动算:(maxOffset - offset) / maxOffset
备选方案(简化版):如果上面架构改动太大,第一版可以退化为:
now_playing仍是独立路由- 进出动画用 Compose Navigation 的自定义
enterTransition/exitTransition(slide-up / slide-down) - 不追求"封面同一个元素"的视觉效果
- 后续有精力再升级成 BottomSheetScaffold
2.3.1 退化方案:Navigation 转场动画(第一版先做这个)
只改 AppNavigation.kt 里 now_playing 的 composable 定义:
1 | composable( |
效果:NowPlaying 从下往上滑入,返回时滑下去消失。MiniPlayer 在此期间简单淡出/淡入。不完美但观感已经接近 Apple Music,工作量十分之一。
2.4 Spring 动效全局替换
目标:把所有默认的 tween 动画替换成 spring,给 app 一个统一的"弹性感"。
新建 ui/theme/MotionDefaults.kt:
1 | package io.kyonqi.musicplayer.ui.theme |
然后在使用动画的地方:
1 | val scale by animateFloatAsState( |
组件检查表
| 组件 | 文件 | 是否要改 | 改动类型 |
|---|---|---|---|
MiniPlayer |
ui/component/MiniPlayer.kt |
✅ | 布局微调(圆角/阴影/间距) |
AlbumArt |
ui/component/AlbumArt.kt |
⚠️ 可选 | 加阴影、圆角参数 |
LibraryScreen |
ui/screen/LibraryScreen.kt |
✅ | 整个废弃(Tab 状态由导航接管) |
SongListScreen |
ui/screen/SongListScreen.kt |
✅ | LargeTopAppBar + 圆角卡片 |
AlbumListScreen |
ui/screen/AlbumListScreen.kt |
✅ | LargeTopAppBar + 网格卡片圆角优化 |
ArtistListScreen |
ui/screen/ArtistListScreen.kt |
✅ | LargeTopAppBar + 圆角卡片 |
FolderBrowserScreen |
ui/screen/FolderBrowserScreen.kt |
✅ | LargeTopAppBar + 圆角卡片 |
AlbumDetailScreen |
ui/screen/AlbumDetailScreen.kt |
✅ | 头部大封面 + 渐变背景 |
ArtistDetailScreen |
ui/screen/ArtistDetailScreen.kt |
✅ | 头部大头像 + 渐变背景 |
NowPlayingScreen |
ui/screen/NowPlayingScreen.kt |
✅ | 动态渐变、spring 动效、slide 转场 |
AppNavigation |
ui/navigation/AppNavigation.kt |
✅ | 底部 TabBar、now_playing 转场动画 |
LibraryScannerViewModel |
ui/screen/LibraryScannerViewModel.kt |
🆕 | 抽出原 LibraryViewModel 的扫描逻辑 |
MusicListItem |
ui/component/MusicListItem.kt |
🆕 | 新建圆角卡片通用组件 |
PaletteExtractor |
ui/theme/PaletteExtractor.kt |
🆕 | 新建封面主色提取工具 |
MotionDefaults |
ui/theme/MotionDefaults.kt |
🆕 | 新建统一动效参数 |
实施顺序(风险排序)
- 第 1 天:新增依赖(Palette,Haze 可选),刷新 Gradle,确认能编译
- 第 2-3 天:
MusicListItem组件 +SongListScreen改造,验证圆角卡片视觉 - 第 4 天:
LargeTopAppBar应用到所有 list 页面 - 第 5-6 天:底部 TabBar 重构(改动 AppNavigation 架构 + Tab 切换
saveState处理) - 第 7 天:
MotionDefaults替换主要动画 - 第 8-10 天:
PaletteExtractor+ NowPlayingScreen 沉浸式渐变 - 第 11 天:先实现退化方案 2.3.1(Navigation 转场动画),验证观感
- 第 12-13 天:可选 — 升级到 2.3 的 BottomSheetScaffold 架构(只有 11 天测试下来不满意才做)
- 第 14 天:联调测试,补边角(切歌时颜色过渡、滚动性能、深浅色主题适配)
不在本方案范围
以下项虽然也属于"仿 Apple Music",但实现代价过大或优先级偏低,暂不做:
- 毛玻璃 TabBar:iOS 的 TabBar 有半透明模糊背景。需要 Haze 库包整个屏幕,性能和调试代价大。可以第一版先用
NavigationBar实色背景 - Rubber-band 过滚动:Android 12+ 的
StretchOverscroll观感不同,Compose 自定义一个接近 iOS 的OverscrollEffect约 300+ 行代码,收益不大 - 封面 3D 翻转 / 3D Touch 长按菜单:iOS 独有交互,Android 没有对应硬件反馈,强行模仿违和
- 歌词滚动(带 karaoke 高亮):需要歌词解析 + 时间轴同步,归入后续功能迭代
- Now Playing 的"Up Next"下拉队列:归入 Phase 3 的队列管理重做
测试清单
- [ ] 冷启动首次打开无封面时(空数据库),UI 不崩溃
- [ ] 切歌时 NowPlayingScreen 背景颜色平滑过渡,不闪白
- [ ] 从 MiniPlayer 点进 NowPlaying 时封面共享转场,无跳变
- [ ] 返回时 NowPlaying 的封面平滑缩回 MiniPlayer 位置
- [ ] 列表滚动 60fps 无掉帧(用 Perfetto 或 Android Studio Profiler 验证)
- [ ] 深色/浅色主题切换立即生效
- [ ] 超长歌名、超长艺术家名字 Ellipsis 正常
- [ ] 超宽屏幕(平板)布局不畸形(可以先放宽要求)
延伸阅读(实施时参考)
- Compose SharedTransitionLayout 官方示例:https://developer.android.com/develop/ui/compose/animation/shared-elements
- Haze 库文档:https://chrisbanes.github.io/haze/
- AndroidX Palette 官方指南:https://developer.android.com/develop/ui/views/graphics/palette-colors
- Spring 参数调参感觉:https://developer.android.com/jetpack/compose/animation/quick-guide
备注
- Apple 的设计版权敏感,本方案只模仿视觉语言和交互模式,不直接复用任何 Apple Music 的图标、配色规范或字体
- 全部改动保持 Material3 组件为底座,避免从零实现一整套 UI 系统
- 实施时如果某个细节 Compose 做不好(比如 spring 手感差),宁可用 Material3 原生动画也不要硬做
说些什么吧!