diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index 6dcc7ed..b6ffc64 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -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 _channelTextShadows = [ Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)), @@ -58,11 +57,14 @@ class HomeBrowseView extends StatefulWidget { class _HomeBrowseViewState extends State { late final HomeBrowseLogic logic; + final ScrollController _scroll = ScrollController(); + bool _channelsPinned = false; @override void initState() { super.initState(); logic = HomeBrowseLogic(history: context.read()); + _scroll.addListener(_onScroll); } @override @@ -73,10 +75,33 @@ class _HomeBrowseViewState extends State { @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 { } 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 { ? _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 { ), ); - /// 频道行叠加在 hero 轮播之上;无 banner 时作为普通行显示。 - Widget _heroWithChannels( - AppLocalizations l10n, - List banners, - List 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), diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 7572dae..57820c3 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -90,16 +90,33 @@ void main() { ); expect(card.borderRadius, BorderRadius.zero); - // 频道行在 hero Stack 内、位于轮播顶部 8 的位置上。 + // 频道行是固定层,不在可滚动列表里,初始仍压在轮播顶部。 expect( find.ancestor( of: find.byKey(const Key('home-channels')), - matching: find.byKey(const Key('home-hero-section')), + matching: find.byKey(const Key('home-browse-page')), ), - findsOneWidget, + findsNothing, ); final channels = tester.getRect(find.byKey(const Key('home-channels'))); - expect(channels.top, closeTo(hero.top + 8, 1)); + expect(channels.top, closeTo(hero.top, 1)); + expect(find.byKey(const Key('home-channels-pinned')), findsNothing); + }); + + testWidgets('should_pin_home_channels_when_scrolling', (tester) async { + await _pumpApp(tester); + final before = tester.getRect(find.byKey(const Key('home-channel-hot'))); + expect(find.byKey(const Key('home-channels-pinned')), findsNothing); + + await tester.drag( + find.byKey(const Key('home-browse-page')), + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-channels-pinned')), findsOneWidget); + final after = tester.getRect(find.byKey(const Key('home-channel-hot'))); + expect(after.top, closeTo(before.top, 0.5)); }); testWidgets('should_open_theater_search_when_home_search_is_tapped', (