feat(ui): 主页收藏/歌单改为跟手 PageView

之前是"横向手势 + 淡入切换",与播放页的跟手滑动不一致。
- 列表区改为 SliverFillRemaining + PageView,直接跟手左右滑
- 媒体库/按钮保持固定,外层 CustomScrollView 不再滚动
- 收藏/歌单各自是独立 ListView,可正常纵向滚动
- 去掉原来的 GestureDetector 和淡入动画
- 标题行指示器继续跟随当前页

真机验证:左右滑动切换正常,指示器跟随。
This commit is contained in:
2026-11-01 12:15:44 +08:00
parent 9a932e92d4
commit 632713d133
+154 -198
View File
@@ -162,6 +162,7 @@ class _HomePageState extends BaseState<HomePage> {
/// 0 = 我的收藏,1 = 歌单列表(左右滑动切换)
int _homeTab = 0;
final PageController _homePageController = PageController();
List<PlaylistSummary> _playlistSummaries = [];
// ========== 滚动控制 ==========
@@ -208,6 +209,7 @@ class _HomePageState extends BaseState<HomePage> {
@override
void dispose() {
_homePageController.dispose();
_scrollController.dispose();
super.dispose();
}
@@ -392,200 +394,165 @@ class _HomePageState extends BaseState<HomePage> {
);
}
Widget _buildHomeSliver() {
Widget _buildFavoritesTab() {
if (_isLoading) {
return const SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
),
return const Center(
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
);
}
return _homeTab == 0 ? _buildFavoritesSliver() : _buildPlaylistsSliver();
}
Widget _buildFavoritesSliver() {
if (_favorites.isEmpty) {
return SliverFillRemaining(
child: AnimatedOpacity(
opacity: 1.0 - _listReveal * 0.3,
duration: const Duration(milliseconds: 100),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite_border, size: 48, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
],
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite_border, size: 48, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
),
],
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final song = _favorites[index];
return TweenAnimationBuilder<double>(
key: ValueKey('fav-$_homeTab-$index'),
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 240),
curve: Curves.easeOutCubic,
builder: (context, t, child) => Opacity(
opacity: t,
child: Transform.translate(
offset: Offset(0, (1 - t) * 10),
child: child,
),
return ListView.builder(
padding: const EdgeInsets.fromLTRB(
20, 0, 20, UIConstants.miniPlayerBottomSpace),
itemCount: _favorites.length,
itemBuilder: (context, index) {
final song = _favorites[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
leading: SongArtwork(
filePath: song.artworkPath,
size: 44,
fallbackIcon: Icons.music_note,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
leading: SongArtwork(
filePath: song.artworkPath,
size: 44,
fallbackIcon: Icons.music_note,
),
title: Text(
song.displayTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
song.displaySubtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(
Icons.favorite,
color: Color(0xFFE05C5C),
size: 20,
),
onPressed: () async {
await SongDatabase().removeFavorite(song.path);
await _loadFavoritesFromDb();
if (mounted) safeSetState(() {});
},
),
onTap: () => _playFavorites(index),
title: Text(
song.displayTitle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
},
childCount: _favorites.length,
),
);
}
Widget _buildPlaylistsSliver() {
if (_playlistSummaries.isEmpty) {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.queue_music, size: 48, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'还没有歌单\n在文件列表长按歌曲即可加入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
],
),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final playlist = _playlistSummaries[index];
return TweenAnimationBuilder<double>(
key: ValueKey('pl-$_homeTab-$index'),
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 240),
curve: Curves.easeOutCubic,
builder: (context, t, child) => Opacity(
opacity: t,
child: Transform.translate(
offset: Offset(0, (1 - t) * 10),
child: child,
),
subtitle: Text(
song.displaySubtitle,
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF1E2A28),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.queue_music,
color: Color(0xFFB8D4D0),
size: 22,
),
trailing: IconButton(
icon: const Icon(
Icons.favorite,
color: Color(0xFFE05C5C),
size: 20,
),
title: Text(
playlist.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${playlist.songCount} 首',
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlaylistDetailPage(
playlistId: playlist.id,
playlistName: playlist.name,
),
),
);
await _loadPlaylistSummaries();
onPressed: () async {
await SongDatabase().removeFavorite(song.path);
await _loadFavoritesFromDb();
if (mounted) safeSetState(() {});
},
),
onTap: () => _playFavorites(index),
),
);
},
childCount: _playlistSummaries.length,
),
},
);
}
Widget _buildPlaylistsTab() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
);
}
if (_playlistSummaries.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.queue_music, size: 48, color: Colors.grey[600]),
const SizedBox(height: 16),
Text(
'还没有歌单\n在文件列表长按歌曲即可加入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(
20, 0, 20, UIConstants.miniPlayerBottomSpace),
itemCount: _playlistSummaries.length,
itemBuilder: (context, index) {
final playlist = _playlistSummaries[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFF1E2A28),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.queue_music,
color: Color(0xFFB8D4D0),
size: 22,
),
),
title: Text(
playlist.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${playlist.songCount} 首',
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
),
onTap: () async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlaylistDetailPage(
playlistId: playlist.id,
playlistName: playlist.name,
),
),
);
await _loadPlaylistSummaries();
if (mounted) safeSetState(() {});
},
),
);
},
);
}
@@ -725,23 +692,9 @@ class _HomePageState extends BaseState<HomePage> {
),
),
Expanded(
child: GestureDetector(
onHorizontalDragEnd: (details) {
final v = details.primaryVelocity ?? 0;
if (v < -200 && _homeTab == 0) {
safeSetState(() => _homeTab = 1);
} else if (v > 200 && _homeTab == 1) {
safeSetState(() => _homeTab = 0);
}
},
child: CustomScrollView(
child: CustomScrollView(
controller: _scrollController,
physics: _mediaLibraryHeight > 0
? MagneticScrollPhysics(
snapPoint: _mediaLibraryHeight,
magneticZoneStart: 0.20,
)
: const ClampingScrollPhysics(),
physics: const NeverScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Opacity(
@@ -961,17 +914,20 @@ class _HomePageState extends BaseState<HomePage> {
),
),
),
SliverPadding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
bottom: UIConstants.miniPlayerBottomSpace,
SliverFillRemaining(
hasScrollBody: true,
child: PageView(
controller: _homePageController,
onPageChanged: (index) =>
safeSetState(() => _homeTab = index),
children: [
_buildFavoritesTab(),
_buildPlaylistsTab(),
],
),
sliver: _buildHomeSliver(),
),
],
),
),
),
],
),