fix(home): 优化短剧列表下拉刷新体验

This commit is contained in:
Zhou Weidong committed 2026-10-22 19:11:30 +08:00
1 parent f69a27bb80
commit 7945f3ffed
8 files changed
+89 -43

No files matched your search

+7
View File
@@ -1124,3 +1124,10 @@ $ flutter test
- 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。 - 播放量贴封面右下角(底 2、右 6),火焰图标 11 + 12/w400 纯文字,间距 4,并保留极淡底部渐变。
- 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。 - 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。
- 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。 - 全部短剧的地区、字幕、配音筛选 UI 已占位;以下接口待补:地区、字幕/配音筛选数据。
## 2026-10-22 短剧列表刷新体验优化
- 公共下拉刷新统一为白指示器、40 displacement、Bouncing + AlwaysScrollable,并在下拉期间屏蔽内容点击与滚动条。
- 首页移除父级 feed 监听,网格、剧单和全部短剧改为局部监听,刷新时保留旧数据及原滚动位置。
- 剧单、全部短剧、追剧沿用相同刷新策略;首帧加载与刷新指示器分离。
- 按当前流程未执行 analyze/test。
+21 -7
View File
@@ -10,7 +10,7 @@ import '../theme/app_colors.dart';
const double _maxPullDistance = 96; const double _maxPullDistance = 96;
const double _refreshThreshold = 56; const double _refreshThreshold = 56;
const double _indicatorHeight = 56; const double _indicatorHeight = 56;
const double _indicatorMaxOffset = 10; const double _indicatorDisplacement = 40;
const Duration _arrowTurnDuration = Duration(milliseconds: 160); const Duration _arrowTurnDuration = Duration(milliseconds: 160);
const Duration _settleDuration = Duration(milliseconds: 200); const Duration _settleDuration = Duration(milliseconds: 200);
@@ -26,7 +26,7 @@ class AppPullToRefresh extends StatefulWidget {
}); });
static const AlwaysScrollableScrollPhysics physics = static const AlwaysScrollableScrollPhysics physics =
AlwaysScrollableScrollPhysics(parent: ClampingScrollPhysics()); AlwaysScrollableScrollPhysics(parent: BouncingScrollPhysics());
final Widget child; final Widget child;
final Future<void> Function() onRefresh; final Future<void> Function() onRefresh;
@@ -146,11 +146,14 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
onNotification: _onScroll, onNotification: _onScroll,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _offsetController, animation: _offsetController,
child: ScrollConfiguration(
behavior: const _NoScrollbarScrollBehavior(),
child: Listener( child: Listener(
onPointerUp: (_) => _release(), onPointerUp: (_) => _release(),
onPointerCancel: (_) => _release(), onPointerCancel: (_) => _release(),
child: widget.child, child: widget.child,
), ),
),
builder: (context, child) { builder: (context, child) {
final offset = _offsetController.value; final offset = _offsetController.value;
return Stack( return Stack(
@@ -160,7 +163,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
key: const Key('pull-refresh-content'), key: const Key('pull-refresh-content'),
offset: Offset(0, offset), offset: Offset(0, offset),
child: IgnorePointer( child: IgnorePointer(
ignoring: _refreshing || _retracting, ignoring: offset > 0 || _refreshing || _retracting,
child: child, child: child,
), ),
), ),
@@ -185,7 +188,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
child: Opacity( child: Opacity(
opacity: progress, opacity: progress,
child: Transform.translate( child: Transform.translate(
offset: Offset(0, _indicatorMaxOffset * progress), offset: Offset(0, _indicatorDisplacement * progress),
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Padding( child: Padding(
@@ -206,7 +209,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
dimension: 18, dimension: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
color: AppColors.blue, color: AppColors.navSelected,
), ),
), ),
l10n.refreshingLabel, l10n.refreshingLabel,
@@ -220,7 +223,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
child: Icon( child: Icon(
Icons.arrow_downward_rounded, Icons.arrow_downward_rounded,
size: 18, size: 18,
color: AppColors.blue, color: AppColors.navSelected,
), ),
), ),
armed ? l10n.releaseToRefresh : l10n.pullToRefresh, armed ? l10n.releaseToRefresh : l10n.pullToRefresh,
@@ -236,7 +239,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
Text( Text(
text, text,
style: const TextStyle( style: const TextStyle(
color: AppColors.blue, color: AppColors.navSelected,
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
@@ -245,3 +248,14 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
); );
} }
} }
class _NoScrollbarScrollBehavior extends MaterialScrollBehavior {
const _NoScrollbarScrollBehavior();
@override
Widget buildScrollbar(
BuildContext context,
Widget child,
ScrollableDetails details,
) => child;
}
-6
View File
@@ -210,20 +210,14 @@ class _HomeTabsState extends State<_HomeTabs> {
void initState() { void initState() {
super.initState(); super.initState();
logic = HomeBinding().create(widget.feedController.dramas); logic = HomeBinding().create(widget.feedController.dramas);
widget.feedController.addListener(_onFeedChanged);
} }
@override @override
void dispose() { void dispose() {
widget.feedController.removeListener(_onFeedChanged);
logic.dispose(); logic.dispose();
super.dispose(); super.dispose();
} }
void _onFeedChanged() {
if (mounted) setState(() {});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return ListenableBuilder(
+21 -14
View File
@@ -152,6 +152,17 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
required HomePrimaryTab tab, required HomePrimaryTab tab,
required List<HomeGridCategoryShortcut> shortcuts, required List<HomeGridCategoryShortcut> shortcuts,
}) { }) {
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 tag = _selectedTags[tab];
final dramas = _dramasFor(tab, tag, shortcuts); final dramas = _dramasFor(tab, tag, shortcuts);
return LayoutBuilder( return LayoutBuilder(
@@ -174,17 +185,7 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
controller: _scrollControllers[tab], controller: _scrollControllers[tab],
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
slivers: [ slivers: [
if (widget.feedController.loadingFirstPage) if (dramas.isEmpty)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
else if (dramas.isEmpty)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: _emptyState(l10n, tab, tag), child: _emptyState(l10n, tab, tag),
@@ -196,7 +197,8 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
), ),
sliver: SliverGrid( sliver: SliverGrid(
key: const Key('home-grid-cards'), key: const Key('home-grid-cards'),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: 3,
crossAxisSpacing: AppDimens.homeGridGap, crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridRowGap, mainAxisSpacing: AppDimens.homeGridRowGap,
@@ -214,12 +216,15 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
if (dramas.isNotEmpty) if (dramas.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), padding: const EdgeInsets.only(
bottom: AppDimens.spaceXl,
),
child: PagedListFooter( child: PagedListFooter(
loading: widget.feedController.loadingMore, loading: widget.feedController.loadingMore,
hasMore: widget.feedController.hasMore, hasMore: widget.feedController.hasMore,
hasItems: dramas.isNotEmpty, hasItems: dramas.isNotEmpty,
errorMessage: widget.feedController.lastError == null errorMessage:
widget.feedController.lastError == null
? null ? null
: l10n.catalogError, : l10n.catalogError,
onRetry: widget.feedController.loadMore, onRetry: widget.feedController.loadMore,
@@ -232,6 +237,8 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
); );
}, },
); );
},
);
} }
Future<void> _refresh() async { Future<void> _refresh() async {
+5 -2
View File
@@ -46,7 +46,7 @@ class _RecommendPageState extends State<RecommendPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>(); final categories = context.read<CategoryController>();
return ColoredBox( return ColoredBox(
key: const Key('recommend-page'), key: const Key('recommend-page'),
color: AppColors.appBackground, color: AppColors.appBackground,
@@ -59,7 +59,10 @@ class _RecommendPageState extends State<RecommendPage> {
child: IndexedStack( child: IndexedStack(
index: _tab.index, index: _tab.index,
children: [ children: [
_recommendFeed(context), ListenableBuilder(
listenable: widget.feedController,
builder: (context, _) => _recommendFeed(context),
),
RecommendSectionsView( RecommendSectionsView(
feedController: widget.feedController, feedController: widget.feedController,
categories: categories, categories: categories,
@@ -44,7 +44,6 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final dramas = _filtered();
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final itemWidth = final itemWidth =
@@ -66,9 +65,18 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
key: const Key('all-dramas-scroll'), key: const Key('all-dramas-scroll'),
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
slivers: [ slivers: [
SliverToBoxAdapter(child: _filters(l10n)), SliverToBoxAdapter(
if (dramas.isEmpty) child: ListenableBuilder(
SliverFillRemaining( listenable: widget.feedController,
builder: (context, _) => _filters(l10n),
),
),
ListenableBuilder(
listenable: widget.feedController,
builder: (context, _) {
final dramas = _filtered();
if (dramas.isEmpty) {
return SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: EmptyState( child: EmptyState(
key: const Key('all-dramas-empty'), key: const Key('all-dramas-empty'),
@@ -81,9 +89,9 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
_genre = null; _genre = null;
}), }),
), ),
) );
else }
SliverPadding( return SliverPadding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal, horizontal: AppDimens.feedPageHorizontal,
), ),
@@ -103,14 +111,18 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
childCount: dramas.length, childCount: dramas.length,
), ),
), ),
);
},
), ),
SliverToBoxAdapter( ListenableBuilder(
listenable: widget.feedController,
builder: (context, _) => SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
child: PagedListFooter( child: PagedListFooter(
loading: widget.feedController.loadingMore, loading: widget.feedController.loadingMore,
hasMore: widget.feedController.hasMore, hasMore: widget.feedController.hasMore,
hasItems: dramas.isNotEmpty, hasItems: _filtered().isNotEmpty,
errorMessage: widget.feedController.lastError == null errorMessage: widget.feedController.lastError == null
? null ? null
: l10n.catalogError, : l10n.catalogError,
@@ -118,6 +130,7 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
), ),
), ),
), ),
),
], ],
), ),
), ),
@@ -33,6 +33,9 @@ class RecommendSectionsView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder(
listenable: Listenable.merge([feedController, categories]),
builder: (context, _) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final source = feedController.dramas; final source = feedController.dramas;
final hits = [...source] final hits = [...source]
@@ -49,7 +52,10 @@ class RecommendSectionsView extends StatelessWidget {
return AppPullToRefresh( return AppPullToRefresh(
key: const Key('recommend-sections-refresh'), key: const Key('recommend-sections-refresh'),
onRefresh: () async { onRefresh: () async {
await Future.wait([feedController.refreshCurrent(), categories.load()]); await Future.wait([
feedController.refreshCurrent(),
categories.load(),
]);
}, },
child: ListView( child: ListView(
key: const Key('recommend-sections-scroll'), key: const Key('recommend-sections-scroll'),
@@ -88,6 +94,8 @@ class RecommendSectionsView extends StatelessWidget {
], ],
), ),
); );
},
);
} }
List<Drama> _costumeDramas(List<Drama> source) { List<Drama> _costumeDramas(List<Drama> source) {
+4 -4
View File
@@ -12,7 +12,7 @@ void main() {
testWidgets('should_freeze_pull_offset_until_refresh_completes', ( testWidgets('should_freeze_pull_offset_until_refresh_completes', (
tester, tester,
) async { ) async {
expect(AppPullToRefresh.physics.parent, isA<ClampingScrollPhysics>()); expect(AppPullToRefresh.physics.parent, isA<BouncingScrollPhysics>());
final refresh = Completer<void>(); final refresh = Completer<void>();
var calls = 0; var calls = 0;
await tester.pumpWidget( await tester.pumpWidget(
@@ -48,7 +48,7 @@ void main() {
expect(_contentOffset(tester), greaterThan(0)); expect(_contentOffset(tester), greaterThan(0));
expect( expect(
tester.widget<Text>(find.text('Pull to refresh')).style?.color, tester.widget<Text>(find.text('Pull to refresh')).style?.color,
AppColors.blue, AppColors.navSelected,
); );
await gesture.moveBy(const Offset(0, 120)); await gesture.moveBy(const Offset(0, 120));
@@ -56,7 +56,7 @@ void main() {
expect(find.text('Release to refresh'), findsOneWidget); expect(find.text('Release to refresh'), findsOneWidget);
expect( expect(
tester.widget<Text>(find.text('Release to refresh')).style?.color, tester.widget<Text>(find.text('Release to refresh')).style?.color,
AppColors.blue, AppColors.navSelected,
); );
await gesture.up(); await gesture.up();
@@ -64,7 +64,7 @@ void main() {
expect(find.text('Refreshing…'), findsOneWidget); expect(find.text('Refreshing…'), findsOneWidget);
expect( expect(
tester.widget<Text>(find.text('Refreshing…')).style?.color, tester.widget<Text>(find.text('Refreshing…')).style?.color,
AppColors.blue, AppColors.navSelected,
); );
expect(calls, 1); expect(calls, 1);
final releaseOffset = _contentOffset(tester); final releaseOffset = _contentOffset(tester);