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 _shimmerTravel = 2.4;
const double _shimmerStart = -1.2; const double _shimmerStart = -1.2;
const double _subtitleWidthFactor = 0.62; const double _subtitleWidthFactor = 0.62;
const double _shimmerPhaseStep = 0.13;
const Color _shimmerHighlight = Color(0x12FFFFFF); const Color _shimmerHighlight = Color(0x12FFFFFF);
/// 首页短剧网格的占位。行数至少铺满可视高度,并带一道扫光。 /// 首页短剧网格的占位。行数至少铺满可视高度,并带一道扫光。
@@ -53,21 +54,19 @@ class _DramaGridSkeletonState extends State<DramaGridSkeleton>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final grid = _grid(constraints.maxWidth, constraints.maxHeight);
return AnimatedBuilder( return AnimatedBuilder(
animation: _shimmer, animation: _shimmer,
builder: (context, child) => ShaderMask( builder: (context, _) => _grid(
blendMode: BlendMode.srcATop, constraints.maxWidth,
shaderCallback: (bounds) => _shader(bounds), constraints.maxHeight,
child: child, _shimmer.value,
), ),
child: grid,
); );
}, },
); );
} }
Widget _grid(double width, double height) { Widget _grid(double width, double height, double shimmerValue) {
final itemWidth = _itemWidth(width); final itemWidth = _itemWidth(width);
final itemExtent = _itemExtent(itemWidth); final itemExtent = _itemExtent(itemWidth);
return GridView.builder( return GridView.builder(
@@ -85,7 +84,8 @@ class _DramaGridSkeletonState extends State<DramaGridSkeleton>
mainAxisExtent: itemExtent, mainAxisExtent: itemExtent,
), ),
itemCount: _fillCount(height, 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; final rows = (height / rowHeight).ceil() + 1;
return math.max(widget.itemCount, rows * widget.columns); 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 { class _SkeletonCard extends StatelessWidget {
const _SkeletonCard({required this.index}); const _SkeletonCard({required this.index, required this.shimmerValue});
final int index; final int index;
final double shimmerValue;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( final phase = (shimmerValue + index * _shimmerPhaseStep) % 1.0;
key: Key('drama-grid-skeleton-$index'), return ShaderMask(
crossAxisAlignment: CrossAxisAlignment.start, blendMode: BlendMode.srcATop,
children: [ shaderCallback: (bounds) => _cardShader(bounds, phase),
AspectRatio( child: Column(
aspectRatio: AppDimens.cardCoverAspectRatio, key: Key('drama-grid-skeleton-$index'),
child: const _SkeletonBlock(), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
const SizedBox(height: AppDimens.cardCoverTitleGap), AspectRatio(
const _SkeletonBar(), aspectRatio: AppDimens.cardCoverAspectRatio,
const SizedBox(height: AppDimens.skeletonTitleBarGap), child: const _SkeletonBlock(),
const FractionallySizedBox( ),
widthFactor: _subtitleWidthFactor, const SizedBox(height: AppDimens.cardCoverTitleGap),
child: _SkeletonBar(), 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 { class _SkeletonBlock extends StatelessWidget {
const _SkeletonBlock(); 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,
);
}
});
}