feat(media): 图片占位改为纯色块并去掉扫光

This commit is contained in:
Zhou Weidong committed 2026-11-02 14:44:28 +08:00
1 parent 613147c20c
commit 7f65fe4d48
20 files changed
+48 -291

No files matched your search

@@ -1,86 +0,0 @@
// 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;
}
+11 -43
View File
@@ -1,10 +1,10 @@
// Author: fruit.com.cn
// 职责:统一网络图片入口,补服务地址,并在加载中/失败时显示纯色占位块。
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;
@@ -32,8 +32,6 @@ class RemoteImage extends StatelessWidget {
this.width,
this.height,
this.fit = BoxFit.cover,
this.showFallbackIcon = false,
this.fallbackIconKey,
this.fallbackAspectRatio,
this.fallbackColor = AppColors.raised,
this.fallbackWidget,
@@ -45,12 +43,10 @@ class RemoteImage extends StatelessWidget {
final double? width;
final double? height;
final BoxFit fit;
final bool showFallbackIcon;
final Key? fallbackIconKey;
final double? fallbackAspectRatio;
final Color fallbackColor;
/// 自定义兜底:地址为空、加载中、加载失败时都显示它(优先于 [fallbackColor])。
/// 自定义占位:地址为空、加载中、加载失败时都显示它(优先于 [fallbackColor])。
final Widget? fallbackWidget;
final int? decodeWidth;
final int? decodeHeight;
@@ -58,7 +54,7 @@ class RemoteImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _fallback(brand: showFallbackIcon);
if (url.isEmpty) return _fallback();
return LayoutBuilder(
builder: (context, constraints) {
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
@@ -100,51 +96,23 @@ class RemoteImage extends StatelessWidget {
?.clamp(_minImageDecodePixels, _maxImageDecodePixels)
.toInt(),
gaplessPlayback: true,
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
errorBuilder: (_, _, _) => _fallback(),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child;
return _loadingFallback();
return _fallback();
},
);
},
);
}
Widget _loadingFallback() {
Widget _fallback() {
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;
final mark = brand
? LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth.isFinite
? constraints.maxWidth
: 120.0;
return Center(
child: Image.asset(
'assets/icon/splash_logo.png',
key: fallbackIconKey,
width: width * 0.72,
fit: BoxFit.contain,
),
);
},
)
: null;
final fallback = Container(
width: width,
height: height,
color: brand ? AppColors.mediaBlack : fallbackColor,
alignment: Alignment.center,
child: mark,
);
final content = custom != null
? SizedBox(width: width, height: height, child: custom)
: Container(width: width, height: height, color: fallbackColor);
final ratio = fallbackAspectRatio;
if (ratio == null) return fallback;
return AspectRatio(aspectRatio: ratio, child: fallback);
if (ratio == null) return content;
return AspectRatio(aspectRatio: ratio, child: content);
}
}
@@ -176,7 +176,7 @@ class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(banner.imageUrl, showFallbackIcon: true),
RemoteImage(banner.imageUrl),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -37,7 +37,6 @@ class SearchResultCard extends StatelessWidget {
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
const Positioned(
@@ -60,7 +59,9 @@ class SearchResultCard extends StatelessWidget {
),
const SizedBox(height: AppDimens.searchResultTitleGap),
Text(
drama.localizedTitle(Localizations.localeOf(context).languageCode),
drama.localizedTitle(
Localizations.localeOf(context).languageCode,
),
key: Key('search-result-title-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -93,7 +94,9 @@ class SearchResultCard extends StatelessWidget {
}
List<String> _tags(BuildContext context) {
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
final localized = drama.localizedTypeLabels(
Localizations.localeOf(context).languageCode,
);
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
final seen = <String>{};
return [drama.genre, drama.category]
@@ -126,7 +126,6 @@ class DramaDetailHeader extends StatelessWidget {
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackAspectRatio: 3 / 4,
),
),
@@ -53,7 +53,6 @@ class FilterResultCard extends StatelessWidget {
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackAspectRatio: 3 / 4,
),
if (drama.isHot)
@@ -64,7 +64,6 @@ class FollowingGridCard extends StatelessWidget {
child: RemoteImage(
posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
),
@@ -75,7 +75,6 @@ class FollowingHistoryCard extends StatelessWidget {
child: RemoteImage(
posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
),
@@ -40,7 +40,6 @@ class ContinueWatchingBanner extends StatelessWidget {
RemoteImage(
record.displayLandscapeCover,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
const DecoratedBox(
+3 -2
View File
@@ -122,7 +122,6 @@ class HomeDramaCard extends StatelessWidget {
drama.posterUrl,
width: double.infinity,
fit: BoxFit.fitWidth,
showFallbackIcon: true,
fallbackAspectRatio: _coverAspectRatio,
),
Positioned(top: 8, left: 8, child: _tagBadge()),
@@ -226,7 +225,9 @@ class HomeDramaCard extends StatelessWidget {
}
Widget _genre(BuildContext context) {
final labels = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
final labels = drama.localizedTypeLabels(
Localizations.localeOf(context).languageCode,
);
final genre = labels.isEmpty
? (drama.genre.isEmpty ? drama.category : drama.genre)
: labels.join(' / ');
+10 -9
View File
@@ -12,11 +12,7 @@ import '../../../core/widgets/remote_image.dart';
final RegExp _tagSplitter = RegExp(r'[,/、·]');
class HomeFeedCard extends StatelessWidget {
const HomeFeedCard({
super.key,
required this.drama,
required this.onTap,
});
const HomeFeedCard({super.key, required this.drama, required this.onTap});
final Drama drama;
final VoidCallback onTap;
@@ -44,7 +40,9 @@ class HomeFeedCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
drama.localizedTitle(Localizations.localeOf(context).languageCode),
drama.localizedTitle(
Localizations.localeOf(context).languageCode,
),
key: Key('home-feed-title-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -69,7 +67,9 @@ class HomeFeedCard extends StatelessWidget {
}
Widget _cover(BuildContext context) {
final label = drama.localizedTag(Localizations.localeOf(context).languageCode);
final label = drama.localizedTag(
Localizations.localeOf(context).languageCode,
);
return ClipRRect(
key: Key('home-feed-cover-clip-${drama.id}'),
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
@@ -82,7 +82,6 @@ class HomeFeedCard extends StatelessWidget {
drama.posterUrl,
key: Key('home-feed-cover-${drama.id}'),
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
if (label.isNotEmpty)
@@ -133,7 +132,9 @@ class HomeFeedCard extends StatelessWidget {
}
List<String> _cardTags(BuildContext context) {
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
final localized = drama.localizedTypeLabels(
Localizations.localeOf(context).languageCode,
);
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
final seen = <String>{};
return [drama.genre, drama.category]
@@ -128,7 +128,6 @@ class MineContinueWatching extends StatelessWidget {
child: RemoteImage(
record.dramaCover ?? '',
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
),
@@ -276,7 +276,6 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackAspectRatio: 16 / 9,
),
const DecoratedBox(
@@ -448,7 +447,6 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackAspectRatio: 3 / 4,
),
Positioned(
+2 -10
View File
@@ -255,11 +255,7 @@ class _DramaInfoSheetState extends State<DramaInfoSheet> {
width: _posterWidth,
child: AspectRatio(
aspectRatio: 3 / 4,
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
child: RemoteImage(drama.posterUrl, fit: BoxFit.cover),
),
),
),
@@ -466,11 +462,7 @@ class _DramaInfoSheetState extends State<DramaInfoSheet> {
width: _relatedWidth,
child: AspectRatio(
aspectRatio: 3 / 4,
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
child: RemoteImage(drama.posterUrl, fit: BoxFit.cover),
),
),
),
@@ -187,7 +187,6 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
RemoteImage(
banner.imageUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
const DecoratedBox(
@@ -210,10 +209,7 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
left: 28,
right: 28,
bottom: _copyBottomInset,
child: _HeroCopy(
key: Key('home-hero-copy-$index'),
drama: drama,
),
child: _HeroCopy(key: Key('home-hero-copy-$index'), drama: drama),
),
if (drama != null)
Positioned.fill(
@@ -97,7 +97,6 @@ class HomePosterCard extends StatelessWidget {
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
if (tagLabel.isNotEmpty)
@@ -189,7 +188,6 @@ class HomeEditorPickBanner extends StatelessWidget {
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
const DecoratedBox(
@@ -310,7 +310,6 @@ class _PlaylistCollage extends StatelessWidget {
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: _filmBody,
),
);
@@ -25,7 +25,9 @@ class RecommendSectionCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tagLabel = drama.localizedTag(Localizations.localeOf(context).languageCode);
final tagLabel = drama.localizedTag(
Localizations.localeOf(context).languageCode,
);
return SizedBox(
width: AppDimens.recommendCardWidth,
child: InkWell(
@@ -47,7 +49,6 @@ class RecommendSectionCard extends StatelessWidget {
RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
if (tagLabel.isNotEmpty)
@@ -80,7 +81,9 @@ class RecommendSectionCard extends StatelessWidget {
),
const SizedBox(height: AppDimens.cardCoverTitleGap),
Text(
drama.localizedTitle(Localizations.localeOf(context).languageCode),
drama.localizedTitle(
Localizations.localeOf(context).languageCode,
),
key: Key('recommend-title-$sectionKey-${drama.id}'),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -116,7 +119,9 @@ class RecommendSectionCard extends StatelessWidget {
}
List<String> _cardTags(BuildContext context) {
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
final localized = drama.localizedTypeLabels(
Localizations.localeOf(context).languageCode,
);
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
final seen = <String>{};
return [drama.genre, drama.category]
@@ -70,9 +70,11 @@ class WatchRecordCard extends StatelessWidget {
key: ValueKey('watch-record-cover-${record.dramaId}'),
width: _coverWidth,
height: _coverHeight,
showFallbackIcon: true,
fallbackIconKey: ValueKey(
'watch-record-cover-placeholder-${record.dramaId}',
fallbackWidget: ColoredBox(
key: ValueKey(
'watch-record-cover-placeholder-${record.dramaId}',
),
color: AppColors.raised,
),
),
),
@@ -1,114 +0,0 @@
// 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());
},
);
}