feat(image): 图片加载占位改为微光骨架
This commit is contained in:
1 parent
335e5dc286
commit
aed9ee2b01
3 files changed
+208
-1
No files matched your search
@@ -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<ImagePlaceholderShimmer> createState() =>
|
||||||
|
_ImagePlaceholderShimmerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImagePlaceholderShimmerState extends State<ImagePlaceholderShimmer>
|
||||||
|
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<double> 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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import 'image_placeholder_shimmer.dart';
|
||||||
|
|
||||||
const int _minImageDecodePixels = 96;
|
const int _minImageDecodePixels = 96;
|
||||||
const int _maxImageDecodePixels = 2048;
|
const int _maxImageDecodePixels = 2048;
|
||||||
@@ -102,13 +103,19 @@ class RemoteImage extends StatelessWidget {
|
|||||||
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
|
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
|
||||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
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}) {
|
Widget _fallback({required bool brand}) {
|
||||||
final custom = fallbackWidget;
|
final custom = fallbackWidget;
|
||||||
if (custom != null) return custom;
|
if (custom != null) return custom;
|
||||||
|
|||||||
@@ -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<CustomPaint>(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<Image>(find.byType(Image));
|
||||||
|
final context = tester.element(find.byType(RemoteImage));
|
||||||
|
const loaded = SizedBox(key: Key('loaded'));
|
||||||
|
expect(
|
||||||
|
image.frameBuilder!(context, loaded, null, false),
|
||||||
|
isA<ImagePlaceholderShimmer>(),
|
||||||
|
);
|
||||||
|
expect(image.frameBuilder!(context, loaded, 0, false), same(loaded));
|
||||||
|
expect(
|
||||||
|
image.errorBuilder!(context, Exception('failed'), null),
|
||||||
|
isNot(isA<ImagePlaceholderShimmer>()),
|
||||||
|
);
|
||||||
|
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());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user