From 7945f3ffedd380c249d61368bd8ea6c050a01f74 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 19:11:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(home):=20=E4=BC=98=E5=8C=96=E7=9F=AD?= =?UTF-8?q?=E5=89=A7=E5=88=97=E8=A1=A8=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 7 + lib/core/widgets/app_pull_to_refresh.dart | 36 ++-- lib/pages/home/home_view.dart | 6 - lib/pages/home/widgets/home_feed_grid.dart | 159 +++++++++--------- lib/pages/recommend/recommend_page.dart | 7 +- .../widgets/all_dramas_filter_grid.dart | 111 ++++++------ .../widgets/recommend_sections_view.dart | 114 +++++++------ test/core/app_pull_to_refresh_test.dart | 8 +- 8 files changed, 247 insertions(+), 201 deletions(-) diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d5589ab..cd8c8a2 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1124,3 +1124,10 @@ $ flutter test - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 + +## 2026-10-22 短剧列表刷新体验优化 + +- 公共下拉刷新统一为白指示器、40 displacement、Bouncing + AlwaysScrollable,并在下拉期间屏蔽内容点击与滚动条。 +- 首页移除父级 feed 监听,网格、剧单和全部短剧改为局部监听,刷新时保留旧数据及原滚动位置。 +- 剧单、全部短剧、追剧沿用相同刷新策略;首帧加载与刷新指示器分离。 +- 按当前流程未执行 analyze/test。 diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart index 1961981..73f807b 100644 --- a/lib/core/widgets/app_pull_to_refresh.dart +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -10,7 +10,7 @@ import '../theme/app_colors.dart'; const double _maxPullDistance = 96; const double _refreshThreshold = 56; const double _indicatorHeight = 56; -const double _indicatorMaxOffset = 10; +const double _indicatorDisplacement = 40; const Duration _arrowTurnDuration = Duration(milliseconds: 160); const Duration _settleDuration = Duration(milliseconds: 200); @@ -26,7 +26,7 @@ class AppPullToRefresh extends StatefulWidget { }); static const AlwaysScrollableScrollPhysics physics = - AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()); + AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics()); final Widget child; final Future Function() onRefresh; @@ -146,10 +146,13 @@ class _AppPullToRefreshState extends State onNotification: _onScroll, child: AnimatedBuilder( animation: _offsetController, - child: Listener( - onPointerUp: (_) => _release(), - onPointerCancel: (_) => _release(), - child: widget.child, + child: ScrollConfiguration( + behavior: const _NoScrollbarScrollBehavior(), + child: Listener( + onPointerUp: (_) => _release(), + onPointerCancel: (_) => _release(), + child: widget.child, + ), ), builder: (context, child) { final offset = _offsetController.value; @@ -160,7 +163,7 @@ class _AppPullToRefreshState extends State key: const Key('pull-refresh-content'), offset: Offset(0, offset), child: IgnorePointer( - ignoring: _refreshing || _retracting, + ignoring: offset > 0 || _refreshing || _retracting, child: child, ), ), @@ -185,7 +188,7 @@ class _AppPullToRefreshState extends State child: Opacity( opacity: progress, child: Transform.translate( - offset: Offset(0, _indicatorMaxOffset * progress), + offset: Offset(0, _indicatorDisplacement * progress), child: Align( alignment: Alignment.topCenter, child: Padding( @@ -206,7 +209,7 @@ class _AppPullToRefreshState extends State dimension: 18, child: CircularProgressIndicator( strokeWidth: 2, - color: AppColors.blue, + color: AppColors.navSelected, ), ), l10n.refreshingLabel, @@ -220,7 +223,7 @@ class _AppPullToRefreshState extends State child: Icon( Icons.arrow_downward_rounded, size: 18, - color: AppColors.blue, + color: AppColors.navSelected, ), ), armed ? l10n.releaseToRefresh : l10n.pullToRefresh, @@ -236,7 +239,7 @@ class _AppPullToRefreshState extends State Text( text, style: const TextStyle( - color: AppColors.blue, + color: AppColors.navSelected, fontSize: 13, fontWeight: FontWeight.w600, ), @@ -245,3 +248,14 @@ class _AppPullToRefreshState extends State ); } } + +class _NoScrollbarScrollBehavior extends MaterialScrollBehavior { + const _NoScrollbarScrollBehavior(); + + @override + Widget buildScrollbar( + BuildContext context, + Widget child, + ScrollableDetails details, + ) => child; +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 2630cf2..d8e309e 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -210,20 +210,14 @@ class _HomeTabsState extends State<_HomeTabs> { void initState() { super.initState(); logic = HomeBinding().create(widget.feedController.dramas); - widget.feedController.addListener(_onFeedChanged); } @override void dispose() { - widget.feedController.removeListener(_onFeedChanged); logic.dispose(); super.dispose(); } - void _onFeedChanged() { - if (mounted) setState(() {}); - } - @override Widget build(BuildContext context) { return ListenableBuilder( diff --git a/lib/pages/home/widgets/home_feed_grid.dart b/lib/pages/home/widgets/home_feed_grid.dart index 70ec7d5..50d3f60 100644 --- a/lib/pages/home/widgets/home_feed_grid.dart +++ b/lib/pages/home/widgets/home_feed_grid.dart @@ -152,83 +152,90 @@ class _HomeFeedGridState extends State { required HomePrimaryTab tab, required List shortcuts, }) { - final tag = _selectedTags[tab]; - final dramas = _dramasFor(tab, tag, shortcuts); - return LayoutBuilder( - builder: (context, constraints) { - final itemWidth = - (constraints.maxWidth - - AppDimens.feedPageHorizontal * 2 - - AppDimens.homeGridGap * 2) / - 3; - final itemExtent = - itemWidth / AppDimens.cardCoverAspectRatio + - AppDimens.homeCardBodyExtent; - return NotificationListener( - onNotification: _onScroll, - child: AppPullToRefresh( - key: const Key('home-grid-refresh'), - onRefresh: _refresh, - child: CustomScrollView( - key: const Key('home-grid-scroll'), - controller: _scrollControllers[tab], - physics: AppPullToRefresh.physics, - slivers: [ - if (widget.feedController.loadingFirstPage) - const SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: _emptyState(l10n, tab, tag), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('home-grid-cards'), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ), - if (dramas.isNotEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, - errorMessage: widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, - ), - ), - ), - ], + return ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) { + if (widget.feedController.loadingFirstPage) { + return const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(strokeWidth: 2), ), - ), + ); + } + final tag = _selectedTags[tab]; + final dramas = _dramasFor(tab, tag, shortcuts); + return LayoutBuilder( + builder: (context, constraints) { + final itemWidth = + (constraints.maxWidth - + AppDimens.feedPageHorizontal * 2 - + AppDimens.homeGridGap * 2) / + 3; + final itemExtent = + itemWidth / AppDimens.cardCoverAspectRatio + + AppDimens.homeCardBodyExtent; + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('home-grid-refresh'), + onRefresh: _refresh, + child: CustomScrollView( + key: const Key('home-grid-scroll'), + controller: _scrollControllers[tab], + physics: AppPullToRefresh.physics, + slivers: [ + if (dramas.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _emptyState(l10n, tab, tag), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('home-grid-cards'), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ), + if (dramas.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only( + bottom: AppDimens.spaceXl, + ), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: dramas.isNotEmpty, + errorMessage: + widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), + ), + ), + ], + ), + ), + ); + }, ); }, ); diff --git a/lib/pages/recommend/recommend_page.dart b/lib/pages/recommend/recommend_page.dart index 341ea03..fb723a5 100644 --- a/lib/pages/recommend/recommend_page.dart +++ b/lib/pages/recommend/recommend_page.dart @@ -46,7 +46,7 @@ class _RecommendPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final categories = context.watch(); + final categories = context.read(); return ColoredBox( key: const Key('recommend-page'), color: AppColors.appBackground, @@ -59,7 +59,10 @@ class _RecommendPageState extends State { child: IndexedStack( index: _tab.index, children: [ - _recommendFeed(context), + ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) => _recommendFeed(context), + ), RecommendSectionsView( feedController: widget.feedController, categories: categories, diff --git a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart index 059e47b..10d2d33 100644 --- a/lib/pages/recommend/widgets/all_dramas_filter_grid.dart +++ b/lib/pages/recommend/widgets/all_dramas_filter_grid.dart @@ -44,7 +44,6 @@ class _AllDramasFilterGridState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final dramas = _filtered(); return LayoutBuilder( builder: (context, constraints) { final itemWidth = @@ -66,55 +65,69 @@ class _AllDramasFilterGridState extends State { key: const Key('all-dramas-scroll'), physics: AppPullToRefresh.physics, slivers: [ - SliverToBoxAdapter(child: _filters(l10n)), - if (dramas.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: EmptyState( - key: const Key('all-dramas-empty'), - icon: Icons.movie_filter_outlined, - title: l10n.homeGridFilterEmpty, - actionLabel: l10n.homeGridClearFilter, - onAction: () => setState(() { - _region = 'all'; - _audio = 'all'; - _genre = null; - }), - ), - ) - else - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: AppDimens.feedPageHorizontal, - ), - sliver: SliverGrid( - key: const Key('all-dramas-grid'), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: AppDimens.homeGridGap, - mainAxisSpacing: AppDimens.homeGridRowGap, - mainAxisExtent: itemExtent, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => HomeFeedCard( - drama: dramas[index], - onTap: () => widget.onOpenDrama(dramas[index]), - ), - childCount: dramas.length, - ), - ), - ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - child: PagedListFooter( - loading: widget.feedController.loadingMore, - hasMore: widget.feedController.hasMore, - hasItems: dramas.isNotEmpty, - errorMessage: widget.feedController.lastError == null - ? null - : l10n.catalogError, - onRetry: widget.feedController.loadMore, + child: ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) => _filters(l10n), + ), + ), + ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) { + final dramas = _filtered(); + if (dramas.isEmpty) { + return SliverFillRemaining( + hasScrollBody: false, + child: EmptyState( + key: const Key('all-dramas-empty'), + icon: Icons.movie_filter_outlined, + title: l10n.homeGridFilterEmpty, + actionLabel: l10n.homeGridClearFilter, + onAction: () => setState(() { + _region = 'all'; + _audio = 'all'; + _genre = null; + }), + ), + ); + } + return SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppDimens.feedPageHorizontal, + ), + sliver: SliverGrid( + key: const Key('all-dramas-grid'), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: AppDimens.homeGridGap, + mainAxisSpacing: AppDimens.homeGridRowGap, + mainAxisExtent: itemExtent, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => HomeFeedCard( + drama: dramas[index], + onTap: () => widget.onOpenDrama(dramas[index]), + ), + childCount: dramas.length, + ), + ), + ); + }, + ), + ListenableBuilder( + listenable: widget.feedController, + builder: (context, _) => SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + child: PagedListFooter( + loading: widget.feedController.loadingMore, + hasMore: widget.feedController.hasMore, + hasItems: _filtered().isNotEmpty, + errorMessage: widget.feedController.lastError == null + ? null + : l10n.catalogError, + onRetry: widget.feedController.loadMore, + ), ), ), ), diff --git a/lib/pages/recommend/widgets/recommend_sections_view.dart b/lib/pages/recommend/widgets/recommend_sections_view.dart index 139c48d..4560103 100644 --- a/lib/pages/recommend/widgets/recommend_sections_view.dart +++ b/lib/pages/recommend/widgets/recommend_sections_view.dart @@ -33,60 +33,68 @@ class RecommendSectionsView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final source = feedController.dramas; - final hits = [...source] - ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - final newest = [...source] - ..sort((a, b) { - final left = publishedAtForDrama(a); - final right = publishedAtForDrama(b); - if (left == null && right == null) return b.id.compareTo(a.id); - if (left == null) return 1; - if (right == null) return -1; - return right.compareTo(left); - }); - return AppPullToRefresh( - key: const Key('recommend-sections-refresh'), - onRefresh: () async { - await Future.wait([feedController.refreshCurrent(), categories.load()]); + return ListenableBuilder( + listenable: Listenable.merge([feedController, categories]), + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + final source = feedController.dramas; + final hits = [...source] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + final newest = [...source] + ..sort((a, b) { + final left = publishedAtForDrama(a); + final right = publishedAtForDrama(b); + if (left == null && right == null) return b.id.compareTo(a.id); + if (left == null) return 1; + if (right == null) return -1; + return right.compareTo(left); + }); + return AppPullToRefresh( + key: const Key('recommend-sections-refresh'), + onRefresh: () async { + await Future.wait([ + feedController.refreshCurrent(), + categories.load(), + ]); + }, + child: ListView( + key: const Key('recommend-sections-scroll'), + physics: AppPullToRefresh.physics, + padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), + children: [ + _section( + context, + l10n, + key: 'hits', + title: l10n.weeklyHitsTitle, + subtitle: l10n.weeklyHitsSubtitle, + dramas: hits.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.hits), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'new', + title: l10n.weeklyNewTitle, + subtitle: l10n.weeklyNewSubtitle, + dramas: newest.take(10).toList(), + onMore: () => onMore(RecommendSectionKind.newReleases), + ), + const SizedBox(height: AppDimens.spaceXl), + _section( + context, + l10n, + key: 'costume', + title: l10n.costumeAwardsTitle, + subtitle: l10n.costumeAwardsSubtitle, + dramas: _costumeDramas(source), + onMore: () => onMore(RecommendSectionKind.costume), + ), + ], + ), + ); }, - child: ListView( - key: const Key('recommend-sections-scroll'), - physics: AppPullToRefresh.physics, - padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), - children: [ - _section( - context, - l10n, - key: 'hits', - title: l10n.weeklyHitsTitle, - subtitle: l10n.weeklyHitsSubtitle, - dramas: hits.take(10).toList(), - onMore: () => onMore(RecommendSectionKind.hits), - ), - const SizedBox(height: AppDimens.spaceXl), - _section( - context, - l10n, - key: 'new', - title: l10n.weeklyNewTitle, - subtitle: l10n.weeklyNewSubtitle, - dramas: newest.take(10).toList(), - onMore: () => onMore(RecommendSectionKind.newReleases), - ), - const SizedBox(height: AppDimens.spaceXl), - _section( - context, - l10n, - key: 'costume', - title: l10n.costumeAwardsTitle, - subtitle: l10n.costumeAwardsSubtitle, - dramas: _costumeDramas(source), - onMore: () => onMore(RecommendSectionKind.costume), - ), - ], - ), ); } diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart index 8cf5e49..e9598a2 100644 --- a/test/core/app_pull_to_refresh_test.dart +++ b/test/core/app_pull_to_refresh_test.dart @@ -12,7 +12,7 @@ void main() { testWidgets('should_freeze_pull_offset_until_refresh_completes', ( tester, ) async { - expect(AppPullToRefresh.physics.parent, isA()); + expect(AppPullToRefresh.physics.parent, isA()); final refresh = Completer(); var calls = 0; await tester.pumpWidget( @@ -48,7 +48,7 @@ void main() { expect(_contentOffset(tester), greaterThan(0)); expect( tester.widget(find.text('Pull to refresh')).style?.color, - AppColors.blue, + AppColors.navSelected, ); await gesture.moveBy(const Offset(0, 120)); @@ -56,7 +56,7 @@ void main() { expect(find.text('Release to refresh'), findsOneWidget); expect( tester.widget(find.text('Release to refresh')).style?.color, - AppColors.blue, + AppColors.navSelected, ); await gesture.up(); @@ -64,7 +64,7 @@ void main() { expect(find.text('Refreshing…'), findsOneWidget); expect( tester.widget(find.text('Refreshing…')).style?.color, - AppColors.blue, + AppColors.navSelected, ); expect(calls, 1); final releaseOffset = _contentOffset(tester);