diff --git a/lib/core/widgets/image_placeholder_shimmer.dart b/lib/core/widgets/image_placeholder_shimmer.dart new file mode 100644 index 0000000..7956e0f --- /dev/null +++ b/lib/core/widgets/image_placeholder_shimmer.dart @@ -0,0 +1,86 @@ +// Author: fruit.com.cn +// 职责:给默认图片加载占位叠加可暂停、局部重绘的轻量扫光。 +import 'package:flutter/material.dart'; + +const _sweepDuration = Duration(milliseconds: 1400); +const double _bandWidthRatio = 0.65; +const _highlight = Color(0x12FFFFFF); +const _clear = Color(0x00FFFFFF); + +/// 只用于等待真实图片的占位;减少动画或页面离屏时不运行扫光。 +class ImagePlaceholderShimmer extends StatefulWidget { + /// 在 [child] 上方绘制不影响布局与点击区域的扫光。 + const ImagePlaceholderShimmer({super.key, required this.child}); + + final Widget child; + + @override + State createState() => + _ImagePlaceholderShimmerState(); +} + +class _ImagePlaceholderShimmerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: _sweepDuration, + ); + bool _active = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _active = + TickerMode.valuesOf(context).enabled && + !MediaQuery.disableAnimationsOf(context); + if (_active) { + _controller.repeat(); + } else { + _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_active) return widget.child; + return RepaintBoundary( + child: ClipRect( + child: CustomPaint( + foregroundPainter: _SweepPainter(_controller), + child: widget.child, + ), + ), + ); + } +} + +class _SweepPainter extends CustomPainter { + _SweepPainter(this.progress) : super(repaint: progress); + + final Animation progress; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + final bandWidth = size.width * _bandWidthRatio; + final left = -bandWidth + (size.width + bandWidth) * progress.value; + final rect = Rect.fromLTWH(left, 0, bandWidth, size.height); + final paint = Paint() + ..shader = const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_clear, _highlight, _clear], + ).createShader(rect); + canvas.drawRect(rect, paint); + } + + @override + bool shouldRepaint(covariant _SweepPainter oldDelegate) => + oldDelegate.progress != progress; +} diff --git a/lib/core/widgets/remote_image.dart b/lib/core/widgets/remote_image.dart index c5bd75c..097883a 100644 --- a/lib/core/widgets/remote_image.dart +++ b/lib/core/widgets/remote_image.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../config/app_config.dart'; import '../theme/app_colors.dart'; +import 'image_placeholder_shimmer.dart'; const int _minImageDecodePixels = 96; const int _maxImageDecodePixels = 2048; @@ -102,13 +103,19 @@ class RemoteImage extends StatelessWidget { errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon), frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded || frame != null) return child; - return _fallback(brand: showFallbackIcon); + return _loadingFallback(); }, ); }, ); } + Widget _loadingFallback() { + final custom = fallbackWidget; + if (custom != null) return custom; + return ImagePlaceholderShimmer(child: _fallback(brand: showFallbackIcon)); + } + Widget _fallback({required bool brand}) { final custom = fallbackWidget; if (custom != null) return custom; diff --git a/test/core/image_placeholder_shimmer_test.dart b/test/core/image_placeholder_shimmer_test.dart new file mode 100644 index 0000000..fac596d --- /dev/null +++ b/test/core/image_placeholder_shimmer_test.dart @@ -0,0 +1,114 @@ +// Author: fruit.com.cn +// 职责:验证图片占位扫光生命周期及默认兜底接入。 +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/widgets/image_placeholder_shimmer.dart'; +import 'package:fruitshorts/core/widgets/remote_image.dart'; + +const _frame = Duration(milliseconds: 350); +const double _width = 120; +const double _height = 160; +const _size = Size(_width, _height); + +Widget _placeholder({bool reduceMotion = false, bool active = true}) { + return MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: TickerMode( + enabled: active, + child: const Center( + child: SizedBox( + width: _width, + height: _height, + child: ImagePlaceholderShimmer( + child: ColoredBox(color: Colors.black), + ), + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('should_paint_clipped_sweep_when_placeholder_is_loading', ( + tester, + ) async { + await tester.pumpWidget(_placeholder()); + final shimmer = find.byType(ImagePlaceholderShimmer); + final paintFinder = find.descendant( + of: shimmer, + matching: find.byType(CustomPaint), + ); + final paint = tester.widget(paintFinder); + var repaints = 0; + paint.foregroundPainter!.addListener(() => repaints++); + await tester.pump(_frame); + expect(repaints, greaterThan(0)); + expect(tester.getSize(shimmer), _size); + expect( + find.descendant(of: shimmer, matching: find.byType(ClipRect)), + findsOneWidget, + ); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(_frame); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('should_pause_sweep_when_motion_is_disabled_or_page_is_hidden', ( + tester, + ) async { + await tester.pumpWidget(_placeholder()); + await tester.pump(_frame); + await tester.pumpWidget(_placeholder(reduceMotion: true)); + await tester.pump(_frame); + expect(tester.binding.hasScheduledFrame, isFalse); + await tester.pumpWidget(_placeholder(active: false)); + await tester.pump(_frame); + expect(tester.binding.hasScheduledFrame, isFalse); + await tester.pumpWidget(_placeholder()); + await tester.pump(_frame); + expect(tester.binding.hasScheduledFrame, isTrue); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets( + 'should_sweep_only_default_loading_fallback_when_image_is_pending', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: SizedBox( + width: _width, + height: _height, + child: RemoteImage('/files/pending-cover.jpg'), + ), + ), + ); + final image = tester.widget(find.byType(Image)); + final context = tester.element(find.byType(RemoteImage)); + const loaded = SizedBox(key: Key('loaded')); + expect( + image.frameBuilder!(context, loaded, null, false), + isA(), + ); + expect(image.frameBuilder!(context, loaded, 0, false), same(loaded)); + expect( + image.errorBuilder!(context, Exception('failed'), null), + isNot(isA()), + ); + await tester.pumpWidget(const MaterialApp(home: RemoteImage(''))); + expect(find.byType(ImagePlaceholderShimmer), findsNothing); + await tester.pumpWidget( + const MaterialApp( + home: RemoteImage( + '/files/custom-cover.jpg', + fallbackWidget: SizedBox(key: Key('custom')), + ), + ), + ); + expect(find.byKey(const Key('custom')), findsOneWidget); + expect(find.byType(ImagePlaceholderShimmer), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); +}