fix(ui): 骨架屏改为逐卡片扫光

This commit is contained in:
Zhou Weidong committed 2026-10-27 11:08:43 +08:00
1 parent 00405d2e3b
commit a5dbae7fff
2 files changed
+91 -38

No files matched your search

+44 -38
View File
@@ -11,6 +11,7 @@ const Duration _shimmerPeriod = Duration(milliseconds: 1400);
const double _shimmerTravel = 2.4;
const double _shimmerStart = -1.2;
const double _subtitleWidthFactor = 0.62;
const double _shimmerPhaseStep = 0.13;
const Color _shimmerHighlight = Color(0x12FFFFFF);
/// 首页短剧网格的占位。行数至少铺满可视高度,并带一道扫光。
@@ -53,21 +54,19 @@ class _DramaGridSkeletonState extends State<DramaGridSkeleton>
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final grid = _grid(constraints.maxWidth, constraints.maxHeight);
return AnimatedBuilder(
animation: _shimmer,
builder: (context, child) => ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) => _shader(bounds),
child: child,
builder: (context, _) => _grid(
constraints.maxWidth,
constraints.maxHeight,
_shimmer.value,
),
child: grid,
);
},
);
}
Widget _grid(double width, double height) {
Widget _grid(double width, double height, double shimmerValue) {
final itemWidth = _itemWidth(width);
final itemExtent = _itemExtent(itemWidth);
return GridView.builder(
@@ -85,7 +84,8 @@ class _DramaGridSkeletonState extends State<DramaGridSkeleton>
mainAxisExtent: itemExtent,
),
itemCount: _fillCount(height, itemExtent),
itemBuilder: (context, index) => _SkeletonCard(index: index),
itemBuilder: (context, index) =>
_SkeletonCard(index: index, shimmerValue: shimmerValue),
);
}
@@ -105,48 +105,54 @@ class _DramaGridSkeletonState extends State<DramaGridSkeleton>
final rows = (height / rowHeight).ceil() + 1;
return math.max(widget.itemCount, rows * widget.columns);
}
Shader _shader(Rect bounds) {
final shift = _shimmerStart + _shimmerTravel * _shimmer.value;
return LinearGradient(
begin: Alignment(shift, -0.3),
end: Alignment(shift + 1, 0.3),
colors: const [
AppColors.skeletonSurface,
_shimmerHighlight,
AppColors.skeletonSurface,
],
).createShader(bounds);
}
}
class _SkeletonCard extends StatelessWidget {
const _SkeletonCard({required this.index});
const _SkeletonCard({required this.index, required this.shimmerValue});
final int index;
final double shimmerValue;
@override
Widget build(BuildContext context) {
return Column(
key: Key('drama-grid-skeleton-$index'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: const _SkeletonBlock(),
),
const SizedBox(height: AppDimens.cardCoverTitleGap),
const _SkeletonBar(),
const SizedBox(height: AppDimens.skeletonTitleBarGap),
const FractionallySizedBox(
widthFactor: _subtitleWidthFactor,
child: _SkeletonBar(),
),
],
final phase = (shimmerValue + index * _shimmerPhaseStep) % 1.0;
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) => _cardShader(bounds, phase),
child: Column(
key: Key('drama-grid-skeleton-$index'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: const _SkeletonBlock(),
),
const SizedBox(height: AppDimens.cardCoverTitleGap),
const _SkeletonBar(),
const SizedBox(height: AppDimens.skeletonTitleBarGap),
const FractionallySizedBox(
widthFactor: _subtitleWidthFactor,
child: _SkeletonBar(),
),
],
),
);
}
}
Shader _cardShader(Rect bounds, double shimmerValue) {
final shift = _shimmerStart + _shimmerTravel * shimmerValue;
return LinearGradient(
begin: Alignment(shift, -0.3),
end: Alignment(shift + 1, 0.3),
colors: const [
AppColors.skeletonSurface,
_shimmerHighlight,
AppColors.skeletonSurface,
],
).createShader(bounds);
}
class _SkeletonBlock extends StatelessWidget {
const _SkeletonBlock();
@@ -0,0 +1,47 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/widgets/drama_grid_skeleton.dart';
void main() {
testWidgets('renders one local shimmer mask per skeleton card', (
tester,
) async {
tester.view.physicalSize = const Size(390, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: DramaGridSkeleton(
columns: 3,
horizontalPadding: 14,
crossAxisSpacing: 8,
mainAxisSpacing: 12,
),
),
),
);
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(GridView), findsOneWidget);
expect(
find.ancestor(
of: find.byType(GridView),
matching: find.byType(ShaderMask),
),
findsNothing,
);
for (var index = 0; index < 9; index++) {
final card = find.byKey(Key('drama-grid-skeleton-$index'));
expect(card, findsOneWidget);
expect(
find.ancestor(of: card, matching: find.byType(ShaderMask)),
findsOneWidget,
);
}
});
}