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),
+21 -4
View File
@@ -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', (