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,并保留极淡底部渐变。
- 修复二级 chip 运行时颜色误用 `const TextStyle` 的编译错误,并校正相关测试 Finder 类型。
- 全部短剧的地区、字幕、配音筛选 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 _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<void> Function() onRefresh;
@@ -146,10 +146,13 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
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<AppPullToRefresh>
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<AppPullToRefresh>
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<AppPullToRefresh>
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.blue,
color: AppColors.navSelected,
),
),
l10n.refreshingLabel,
@@ -220,7 +223,7 @@ class _AppPullToRefreshState extends State<AppPullToRefresh>
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<AppPullToRefresh>
Text(
text,
style: const TextStyle(
color: AppColors.blue,
color: AppColors.navSelected,
fontSize: 13,
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() {
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(
+83 -76
View File
@@ -152,83 +152,90 @@ class _HomeFeedGridState extends State<HomeFeedGrid> {
required HomePrimaryTab tab,
required List<HomeGridCategoryShortcut> 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<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,
),
),
),
],
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<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
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>();
final categories = context.read<CategoryController>();
return ColoredBox(
key: const Key('recommend-page'),
color: AppColors.appBackground,
@@ -59,7 +59,10 @@ class _RecommendPageState extends State<RecommendPage> {
child: IndexedStack(
index: _tab.index,
children: [
_recommendFeed(context),
ListenableBuilder(
listenable: widget.feedController,
builder: (context, _) => _recommendFeed(context),
),
RecommendSectionsView(
feedController: widget.feedController,
categories: categories,
@@ -44,7 +44,6 @@ class _AllDramasFilterGridState extends State<AllDramasFilterGrid> {
@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<AllDramasFilterGrid> {
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,
),
),
),
),
@@ -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),
),
],
),
);
}
+4 -4
View File
@@ -12,7 +12,7 @@ void main() {
testWidgets('should_freeze_pull_offset_until_refresh_completes', (
tester,
) async {
expect(AppPullToRefresh.physics.parent, isA<ClampingScrollPhysics>());
expect(AppPullToRefresh.physics.parent, isA<BouncingScrollPhysics>());
final refresh = Completer<void>();
var calls = 0;
await tester.pumpWidget(
@@ -48,7 +48,7 @@ void main() {
expect(_contentOffset(tester), greaterThan(0));
expect(
tester.widget<Text>(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<Text>(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<Text>(find.text('Refreshing…')).style?.color,
AppColors.blue,
AppColors.navSelected,
);
expect(calls, 1);
final releaseOffset = _contentOffset(tester);