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 = 歌单列表(左右滑动切换) /// 0 = 我的收藏,1 = 歌单列表(左右滑动切换)
int _homeTab = 0; int _homeTab = 0;
final PageController _homePageController = PageController();
List<PlaylistSummary> _playlistSummaries = []; List<PlaylistSummary> _playlistSummaries = [];
// ========== 滚动控制 ========== // ========== 滚动控制 ==========
@@ -208,6 +209,7 @@ class _HomePageState extends BaseState<HomePage> {
@override @override
void dispose() { void dispose() {
_homePageController.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@@ -392,200 +394,165 @@ class _HomePageState extends BaseState<HomePage> {
); );
} }
Widget _buildHomeSliver() { Widget _buildFavoritesTab() {
if (_isLoading) { if (_isLoading) {
return const SliverFillRemaining( return const Center(
child: Center( child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
),
); );
} }
return _homeTab == 0 ? _buildFavoritesSliver() : _buildPlaylistsSliver();
}
Widget _buildFavoritesSliver() {
if (_favorites.isEmpty) { if (_favorites.isEmpty) {
return SliverFillRemaining( return Center(
child: AnimatedOpacity( child: Column(
opacity: 1.0 - _listReveal * 0.3, mainAxisAlignment: MainAxisAlignment.center,
duration: const Duration(milliseconds: 100), children: [
child: Center( Icon(Icons.favorite_border, size: 48, color: Colors.grey[600]),
child: Column( const SizedBox(height: 16),
mainAxisAlignment: MainAxisAlignment.center, Text(
children: [ '还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
Icon(Icons.favorite_border, size: 48, color: Colors.grey[600]), textAlign: TextAlign.center,
const SizedBox(height: 16), style: TextStyle(
Text( fontSize: 16,
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加', color: Colors.grey[500],
textAlign: TextAlign.center, height: 1.6,
style: TextStyle( ),
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
],
), ),
), ],
), ),
); );
} }
return SliverList( return ListView.builder(
delegate: SliverChildBuilderDelegate( padding: const EdgeInsets.fromLTRB(
(context, index) { 20, 0, 20, UIConstants.miniPlayerBottomSpace),
final song = _favorites[index]; itemCount: _favorites.length,
return TweenAnimationBuilder<double>( itemBuilder: (context, index) {
key: ValueKey('fav-$_homeTab-$index'), final song = _favorites[index];
tween: Tween(begin: 0.0, end: 1.0), return Padding(
duration: const Duration(milliseconds: 240), padding: const EdgeInsets.symmetric(vertical: 2),
curve: Curves.easeOutCubic, child: ListTile(
builder: (context, t, child) => Opacity( contentPadding: EdgeInsets.zero,
opacity: t, visualDensity: VisualDensity.compact,
child: Transform.translate( leading: SongArtwork(
offset: Offset(0, (1 - t) * 10), filePath: song.artworkPath,
child: child, size: 44,
), fallbackIcon: Icons.music_note,
), ),
child: Padding( title: Text(
padding: const EdgeInsets.symmetric(vertical: 2), song.displayTitle,
child: ListTile( style: const TextStyle(
contentPadding: EdgeInsets.zero, fontSize: 16,
visualDensity: VisualDensity.compact, fontWeight: FontWeight.w400,
leading: SongArtwork( color: Colors.white,
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),
), ),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
); subtitle: Text(
}, song.displaySubtitle,
childCount: _favorites.length, style: TextStyle(fontSize: 13, color: Colors.grey[400]),
), maxLines: 1,
); overflow: TextOverflow.ellipsis,
}
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,
),
), ),
child: Padding( trailing: IconButton(
padding: const EdgeInsets.symmetric(vertical: 2), icon: const Icon(
child: ListTile( Icons.favorite,
contentPadding: EdgeInsets.zero, color: Color(0xFFE05C5C),
visualDensity: VisualDensity.compact, size: 20,
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( onPressed: () async {
playlist.name, await SongDatabase().removeFavorite(song.path);
style: const TextStyle( await _loadFavoritesFromDb();
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(() {}); 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( Expanded(
child: GestureDetector( child: CustomScrollView(
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(
controller: _scrollController, controller: _scrollController,
physics: _mediaLibraryHeight > 0 physics: const NeverScrollableScrollPhysics(),
? MagneticScrollPhysics(
snapPoint: _mediaLibraryHeight,
magneticZoneStart: 0.20,
)
: const ClampingScrollPhysics(),
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Opacity( child: Opacity(
@@ -961,17 +914,20 @@ class _HomePageState extends BaseState<HomePage> {
), ),
), ),
), ),
SliverPadding( SliverFillRemaining(
padding: const EdgeInsets.only( hasScrollBody: true,
left: 20, child: PageView(
right: 20, controller: _homePageController,
bottom: UIConstants.miniPlayerBottomSpace, onPageChanged: (index) =>
safeSetState(() => _homeTab = index),
children: [
_buildFavoritesTab(),
_buildPlaylistsTab(),
],
), ),
sliver: _buildHomeSliver(),
), ),
], ],
), ),
),
), ),
], ],
), ),