merge: 优化短剧列表下拉刷新体验

This commit is contained in:
Zhou Weidong committed 2026-10-22 19:11:36 +08:00
commit 4291031ace
8 files changed
+247 -201

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。
+25 -11
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,10 +146,13 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
onNotification: _onScroll, onNotification: _onScroll,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _offsetController, animation: _offsetController,
child: Listener( child: ScrollConfiguration(
onPointerUp: (_) => _release(), behavior: const _NoScrollbarScrollBehavior(),
onPointerCancel: (_) => _release(), child: Listener(
child: widget.child, onPointerUp: (_) => _release(),
onPointerCancel: (_) => _release(),
child: widget.child,
),
), ),
builder: (context, child) { builder: (context, child) {
final offset = _offsetController.value; final offset = _offsetController.value;
@@ -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(
+83 -76
View File
@@ -152,83 +152,90 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
required HomePrimaryTab tab, required HomePrimaryTab tab,
required List<HomeGridCategoryShortcut> shortcuts, required List<HomeGridCategoryShortcut> shortcuts,
}) { }) {
final tag = _selectedTags[tab]; return ListenableBuilder(
final dramas = _dramasFor(tab, tag, shortcuts); listenable: widget.feedController,
return LayoutBuilder( builder: (context, _) {
builder: (context, constraints) { if (widget.feedController.loadingFirstPage) {
final itemWidth = return const Center(
(constraints.maxWidth - child: SizedBox.square(
AppDimens.feedPageHorizontal * 2 - dimension: 24,
AppDimens.homeGridGap * 2) / child: CircularProgressIndicator(strokeWidth: 2),
3;
final itemExtent =
itemWidth / AppDimens.cardCoverAspectRatio +
AppDimens.homeCardBodyExtent;
return NotificationListener<ScrollNotification>(
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,
),
),
),
],
), ),
), );
}
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<ScrollNotification>(
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,
),
),
),
],
),
),
);
},
); );
}, },
); );
+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,55 +65,69 @@ 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)),
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( SliverToBoxAdapter(
child: Padding( child: ListenableBuilder(
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), listenable: widget.feedController,
child: PagedListFooter( builder: (context, _) => _filters(l10n),
loading: widget.feedController.loadingMore, ),
hasMore: widget.feedController.hasMore, ),
hasItems: dramas.isNotEmpty, ListenableBuilder(
errorMessage: widget.feedController.lastError == null listenable: widget.feedController,
? null builder: (context, _) {
: l10n.catalogError, final dramas = _filtered();
onRetry: widget.feedController.loadMore, 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,
),
), ),
), ),
), ),
@@ -33,60 +33,68 @@ class RecommendSectionsView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; return ListenableBuilder(
final source = feedController.dramas; listenable: Listenable.merge([feedController, categories]),
final hits = [...source] builder: (context, _) {
..sort((a, b) => b.viewCount.compareTo(a.viewCount)); final l10n = AppLocalizations.of(context)!;
final newest = [...source] final source = feedController.dramas;
..sort((a, b) { final hits = [...source]
final left = publishedAtForDrama(a); ..sort((a, b) => b.viewCount.compareTo(a.viewCount));
final right = publishedAtForDrama(b); final newest = [...source]
if (left == null && right == null) return b.id.compareTo(a.id); ..sort((a, b) {
if (left == null) return 1; final left = publishedAtForDrama(a);
if (right == null) return -1; final right = publishedAtForDrama(b);
return right.compareTo(left); if (left == null && right == null) return b.id.compareTo(a.id);
}); if (left == null) return 1;
return AppPullToRefresh( if (right == null) return -1;
key: const Key('recommend-sections-refresh'), return right.compareTo(left);
onRefresh: () async { });
await Future.wait([feedController.refreshCurrent(), categories.load()]); 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),
),
],
),
); );
} }
+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);