双列网格原来是两个 Column 一次性建出所有卡片,翻几页后上百张卡连同封面都留在树上; 现在改成按行的 SliverList,只构建可见行,剧场页/新片页/搜索结果的滚动容器一并换成 sliver。 搜索结果、加载失败等状态同步改成 sliver,滚动位置与下拉刷新行为保持不变。
This commit is contained in:
1 parent
4aaa5bbd60
commit
fe49527dd8
6 files changed
+193
-145
No files matched your search
@@ -153,14 +153,19 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
edgeOffset: _stickyHeaderHeight + 8,
|
||||
onRefresh: () =>
|
||||
Future.wait([categories.load(), theater.refreshCurrent()]),
|
||||
child: ListView(
|
||||
child: CustomScrollView(
|
||||
controller: logic.scroll,
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: EdgeInsets.only(
|
||||
top: showHeader ? _stickyHeaderHeight : 0,
|
||||
bottom: AppDimens.spaceXl,
|
||||
),
|
||||
children: _content(l10n, categories),
|
||||
slivers: [
|
||||
if (showHeader)
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: _stickyHeaderHeight),
|
||||
),
|
||||
..._content(l10n, categories),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: AppDimens.spaceXl),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -228,12 +233,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
];
|
||||
}
|
||||
if (categories.loading && categories.categories.isEmpty) {
|
||||
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
}
|
||||
final error = categories.error;
|
||||
if (error != null) {
|
||||
return [
|
||||
_block(
|
||||
_sliverBlock(
|
||||
EmptyState(
|
||||
key: const Key('home-categories-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
@@ -253,12 +258,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
];
|
||||
}
|
||||
if (theater.loadingFirstPage) {
|
||||
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
}
|
||||
final theaterError = theater.lastError;
|
||||
if (theaterError != null && theater.dramas.isEmpty) {
|
||||
return [
|
||||
_block(
|
||||
_sliverBlock(
|
||||
EmptyState(
|
||||
key: const Key('home-categories-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
@@ -282,38 +287,49 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
List<Widget> _theaterBlocks(AppLocalizations l10n) {
|
||||
final dramas = theater.dramas;
|
||||
return [
|
||||
_toolbar(),
|
||||
SliverToBoxAdapter(child: _toolbar()),
|
||||
if (dramas.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: EmptyState(
|
||||
key: const Key('discover-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.noStories,
|
||||
subtitle: l10n.emptyHint,
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: EmptyState(
|
||||
key: const Key('discover-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.noStories,
|
||||
subtitle: l10n.emptyHint,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama),
|
||||
SliverDramaGrid(
|
||||
key: const Key('home-all-dramas-grid'),
|
||||
l10n: l10n,
|
||||
dramas: dramas,
|
||||
onOpen: _openDrama,
|
||||
),
|
||||
if (theater.loadingMore)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
key: Key('theater-load-more'),
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
key: Key('theater-load-more'),
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (dramas.isNotEmpty && !theater.hasMore)
|
||||
Padding(
|
||||
key: const Key('paged-list-no-more'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
l10n.noMoreItems,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
key: const Key('paged-list-no-more'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
l10n.noMoreItems,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
@@ -401,6 +417,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
Widget _sliverBlock(Widget child) => SliverToBoxAdapter(child: _block(child));
|
||||
|
||||
Widget _block(Widget child) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 280),
|
||||
child: Center(child: child),
|
||||
|
||||
@@ -10,6 +10,8 @@ import '../../../l10n/app_localizations.dart';
|
||||
import 'drama_grid.dart';
|
||||
|
||||
/// 剧场搜索结果:加载、失败与空结果都留在同一页内。
|
||||
///
|
||||
/// 自身是 sliver,好让结果网格跟着剧场页一起懒加载。
|
||||
class DiscoverSearchResults extends StatelessWidget {
|
||||
const DiscoverSearchResults({
|
||||
super.key,
|
||||
@@ -26,16 +28,24 @@ class DiscoverSearchResults extends StatelessWidget {
|
||||
final VoidCallback onRetry;
|
||||
final ValueChanged<Drama> onOpen;
|
||||
|
||||
static const EdgeInsets _topPadding = EdgeInsets.only(top: 12);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _content(l10n),
|
||||
if (!loading && error == null && results.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: _topPadding,
|
||||
sliver: SliverDramaGrid(l10n: l10n, dramas: results, onOpen: onOpen),
|
||||
);
|
||||
}
|
||||
return SliverPadding(
|
||||
padding: _topPadding,
|
||||
sliver: SliverToBoxAdapter(child: _state(l10n)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(AppLocalizations l10n) {
|
||||
Widget _state(AppLocalizations l10n) {
|
||||
if (loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 80),
|
||||
@@ -61,14 +71,11 @@ class DiscoverSearchResults extends StatelessWidget {
|
||||
onAction: onRetry,
|
||||
);
|
||||
}
|
||||
if (results.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('discover-search-empty'),
|
||||
icon: Icons.search_off_rounded,
|
||||
title: l10n.noSearchResults,
|
||||
subtitle: l10n.emptyHint,
|
||||
);
|
||||
}
|
||||
return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen);
|
||||
return EmptyState(
|
||||
key: const Key('discover-search-empty'),
|
||||
icon: Icons.search_off_rounded,
|
||||
title: l10n.noSearchResults,
|
||||
subtitle: l10n.emptyHint,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const int _columns = 2;
|
||||
const double _columnGap = 8;
|
||||
const double _cardGap = 14;
|
||||
const double _gridHorizontalPadding = 12;
|
||||
@@ -29,8 +28,10 @@ const Color _tagChipBackground = AppColors.raised;
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
/// 剧场/分类页共用的双列剧目网格。
|
||||
class DramaGrid extends StatelessWidget {
|
||||
const DramaGrid({
|
||||
///
|
||||
/// 按行懒加载:翻到第 N 页时只构建当前可见的几张卡,长列表不会把整本目录都留在树上。
|
||||
class SliverDramaGrid extends StatelessWidget {
|
||||
const SliverDramaGrid({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.dramas,
|
||||
@@ -43,33 +44,41 @@ class DramaGrid extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
key: const Key('home-all-dramas-section'),
|
||||
final rowCount = (dramas.length + 1) ~/ 2;
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding),
|
||||
child: Row(
|
||||
key: const Key('home-all-dramas-grid'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _column(0)),
|
||||
const SizedBox(width: _columnGap),
|
||||
Expanded(child: _column(1)),
|
||||
],
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, row) {
|
||||
final left = dramas[row * 2];
|
||||
final rightIndex = row * 2 + 1;
|
||||
final right = rightIndex < dramas.length ? dramas[rightIndex] : null;
|
||||
return Padding(
|
||||
// 行间距沿用卡片间距,首行不需要。
|
||||
padding: EdgeInsets.only(top: row == 0 ? 0 : _cardGap),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _card(left)),
|
||||
const SizedBox(width: _columnGap),
|
||||
Expanded(
|
||||
child: right == null
|
||||
? const SizedBox.shrink()
|
||||
: _card(right),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}, childCount: rowCount),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _column(int start) {
|
||||
final children = <Widget>[];
|
||||
for (var index = start; index < dramas.length; index += _columns) {
|
||||
final drama = dramas[index];
|
||||
children.add(
|
||||
DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)),
|
||||
);
|
||||
if (index + _columns < dramas.length) {
|
||||
children.add(const SizedBox(height: _cardGap));
|
||||
}
|
||||
}
|
||||
return Column(children: children);
|
||||
Widget _card(Drama drama) {
|
||||
return DramaGridCard(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
onTap: () => onOpen(drama),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,64 +135,81 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('new-releases-refresh'),
|
||||
onRefresh: _refresh,
|
||||
child: ListView(
|
||||
child: CustomScrollView(
|
||||
key: const Key('new-releases-list'),
|
||||
controller: _scroll,
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: hero == null
|
||||
slivers: hero == null
|
||||
? [
|
||||
SizedBox(
|
||||
height: 420,
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.noStories,
|
||||
key: const Key('new-releases-empty'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 420,
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.noStories,
|
||||
key: const Key('new-releases-empty'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: _heroCard(context, l10n, hero),
|
||||
),
|
||||
child: _heroCard(context, l10n, hero),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const _FilmStrip(),
|
||||
const SizedBox(height: 14),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 14),
|
||||
),
|
||||
const SliverToBoxAdapter(child: _FilmStrip()),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 14),
|
||||
),
|
||||
if (more.isNotEmpty) ...[
|
||||
_moreHeader(l10n),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: _rowHeight,
|
||||
child: ListView.separated(
|
||||
key: const Key('new-releases-row'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
SliverToBoxAdapter(
|
||||
child: _moreHeader(l10n),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 10),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: _rowHeight,
|
||||
child: ListView.separated(
|
||||
key: const Key('new-releases-row'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
itemCount: more.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) =>
|
||||
_moreCard(
|
||||
context,
|
||||
l10n,
|
||||
more[index],
|
||||
index,
|
||||
),
|
||||
),
|
||||
itemCount: more.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) =>
|
||||
_moreCard(
|
||||
context,
|
||||
l10n,
|
||||
more[index],
|
||||
index,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_allHeader(l10n),
|
||||
const SizedBox(height: 10),
|
||||
DramaGrid(
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 20),
|
||||
),
|
||||
SliverToBoxAdapter(child: _allHeader(l10n)),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 10),
|
||||
),
|
||||
SliverDramaGrid(
|
||||
key: const Key('new-releases-all-grid'),
|
||||
l10n: l10n,
|
||||
dramas: visible,
|
||||
@@ -200,12 +217,17 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
playDramaFromCatalog(context, drama),
|
||||
),
|
||||
),
|
||||
PagedLoadFooter(
|
||||
loading: pager.loading,
|
||||
hasMore: pager.hasMore,
|
||||
hasItems: visible.isNotEmpty,
|
||||
loadMoreKey: const Key(
|
||||
'new-releases-load-more',
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: PagedLoadFooter(
|
||||
loading: pager.loading,
|
||||
hasMore: pager.hasMore,
|
||||
hasItems: visible.isNotEmpty,
|
||||
loadMoreKey: const Key(
|
||||
'new-releases-load-more',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user