feat(flutter): 首页频道行滚动吸顶

This commit is contained in:
Zhou Weidong committed 2026-10-20 12:56:13 +08:00
1 parent cfc41efddc
commit 7cc65b7b09
2 files changed
+104 -44

No files matched your search

+83 -40
View File
@@ -25,7 +25,6 @@ const double _channelGap = 20;
const double _channelUnderlineWidth = 18;
const double _channelUnderlineHeight = 2;
const double _channelUnderlineGap = 4;
const double _channelOverlayTop = 8;
const double _channelFontSize = 15;
const List<Shadow> _channelTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
@@ -58,11 +57,14 @@ class HomeBrowseView extends StatefulWidget {
class _HomeBrowseViewState extends State<HomeBrowseView> {
late final HomeBrowseLogic logic;
final ScrollController _scroll = ScrollController();
bool _channelsPinned = false;
@override
void initState() {
super.initState();
logic = HomeBrowseLogic(history: context.read<WatchHistoryRepository>());
_scroll.addListener(_onScroll);
}
@override
@@ -73,10 +75,33 @@ class _HomeBrowseViewState extends State<HomeBrowseView> {
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
logic.dispose();
super.dispose();
}
/// 滚过 hero 后频道行切成吸顶态,未过阈值时仍叠在轮播图上。
void _onScroll() {
if (!_scroll.hasClients) return;
final pinned =
_scroll.offset >= AppDimens.heroBannerHeight - _channelRowHeight;
if (pinned != _channelsPinned) {
setState(() => _channelsPinned = pinned);
}
}
/// 频道行只横向滚动,纵向拖动转交给页面列表,避免挡住上滑。
void _scrollPageBy(DragUpdateDetails details) {
if (!_scroll.hasClients) return;
final position = _scroll.position;
_scroll.jumpTo(
(_scroll.offset - details.delta.dy)
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble(),
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
@@ -103,19 +128,63 @@ class _HomeBrowseViewState extends State<HomeBrowseView> {
}
Widget _body(AppLocalizations l10n, CatalogController catalog) {
return RefreshIndicator(
key: const Key('home-refresh'),
color: AppColors.blue,
backgroundColor: AppColors.card,
onRefresh: () async {
await Future.wait([catalog.load(), logic.refreshRecent()]);
},
child: ListView(
key: const Key('home-browse-page'),
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
children: _sections(l10n, catalog),
final source = catalog.dramas;
final banners = catalog.banners.isEmpty
? _fallbackBanners(source)
: catalog.banners;
final hasBanner = banners.isNotEmpty;
final pinned = !hasBanner || _channelsPinned;
return Stack(
children: [
Positioned.fill(
child: RefreshIndicator(
key: const Key('home-refresh'),
color: AppColors.blue,
backgroundColor: AppColors.card,
onRefresh: () async {
await Future.wait([catalog.load(), logic.refreshRecent()]);
},
child: ListView(
key: const Key('home-browse-page'),
controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
children: _sections(l10n, catalog),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: _channelBar(
l10n,
pinned: pinned,
overImage: hasBanner && !pinned,
),
),
],
);
}
Widget _channelBar(
AppLocalizations l10n, {
required bool pinned,
required bool overImage,
}) {
final content = GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: _scrollPageBy,
child: _channels(l10n, overImage: overImage),
);
if (!pinned) return content;
return DecoratedBox(
key: const Key('home-channels-pinned'),
decoration: const BoxDecoration(
color: AppColors.ink,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: content,
);
}
@@ -127,10 +196,7 @@ class _HomeBrowseViewState extends State<HomeBrowseView> {
? _fallbackBanners(source)
: catalog.banners;
return [
if (banners.isNotEmpty)
_heroWithChannels(l10n, banners, source)
else
_channels(l10n),
if (banners.isNotEmpty) _hero(banners, source),
if (continueItem != null) ...[
_continueHeader(l10n),
ContinueWatchingCard(
@@ -195,29 +261,6 @@ class _HomeBrowseViewState extends State<HomeBrowseView> {
),
);
/// 频道行叠加在 hero 轮播之上;无 banner 时作为普通行显示。
Widget _heroWithChannels(
AppLocalizations l10n,
List<FeedBanner> banners,
List<Drama> dramas,
) {
return SizedBox(
key: const Key('home-hero-section'),
height: AppDimens.heroBannerHeight,
child: Stack(
children: [
Positioned.fill(child: _hero(banners, dramas)),
Positioned(
top: _channelOverlayTop,
left: 0,
right: 0,
child: _channels(l10n, overImage: true),
),
],
),
);
}
Widget _channels(AppLocalizations l10n, {bool overImage = false}) {
final entries = [
(HomeChannel.hot, l10n.hot),