1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'package:flutter/foundation.dart';
6
7import 'basic.dart';
8import 'framework.dart';
9import 'image.dart';
10import 'implicit_animations.dart';
11
12// Examples can assume:
13// late Uint8List bytes;
14
15/// An image that shows a [placeholder] image while the target [image] is
16/// loading, then fades in the new image when it loads.
17///
18/// Use this class to display long-loading images, such as [NetworkImage.new],
19/// so that the image appears on screen with a graceful animation rather than
20/// abruptly popping onto the screen.
21///
22/// {@youtube 560 315 https://www.youtube.com/watch?v=pK738Pg9cxc}
23///
24/// If the [image] emits an [ImageInfo] synchronously, such as when the image
25/// has been loaded and cached, the [image] is displayed immediately, and the
26/// [placeholder] is never displayed.
27///
28/// The [fadeOutDuration] and [fadeOutCurve] properties control the fade-out
29/// animation of the [placeholder].
30///
31/// The [fadeInDuration] and [fadeInCurve] properties control the fade-in
32/// animation of the target [image].
33///
34/// Prefer a [placeholder] that's already cached so that it is displayed
35/// immediately. This prevents it from popping onto the screen.
36///
37/// When [image] changes, it is resolved to a new [ImageStream]. If the new
38/// [ImageStream.key] is different, this widget subscribes to the new stream and
39/// replaces the displayed image with images emitted by the new stream.
40///
41/// When [placeholder] changes and the [image] has not yet emitted an
42/// [ImageInfo], then [placeholder] is resolved to a new [ImageStream]. If the
43/// new [ImageStream.key] is different, this widget subscribes to the new stream
44/// and replaces the displayed image to images emitted by the new stream.
45///
46/// When either [placeholder] or [image] changes, this widget continues showing
47/// the previously loaded image (if any) until the new image provider provides a
48/// different image. This is known as "gapless playback" (see also
49/// [Image.gaplessPlayback]).
50///
51/// {@tool snippet}
52///
53/// ```dart
54/// FadeInImage(
55/// // here `bytes` is a Uint8List containing the bytes for the in-memory image
56/// placeholder: MemoryImage(bytes),
57/// image: const NetworkImage('https://backend.example.com/image.png'),
58/// )
59/// ```
60/// {@end-tool}
61class FadeInImage extends StatefulWidget {
62 /// Creates a widget that displays a [placeholder] while an [image] is loading,
63 /// then fades-out the placeholder and fades-in the image.
64 ///
65 /// The [placeholder] and [image] may be composed in a [ResizeImage] to provide
66 /// a custom decode/cache size.
67 ///
68 /// The [placeholder] and [image] may have their own BoxFit settings via [fit]
69 /// and [placeholderFit].
70 ///
71 /// The [placeholder] and [image] may have their own FilterQuality settings via [filterQuality]
72 /// and [placeholderFilterQuality].
73 ///
74 /// If [excludeFromSemantics] is true, then [imageSemanticLabel] will be ignored.
75 const FadeInImage({
76 super.key,
77 required this.placeholder,
78 this.placeholderErrorBuilder,
79 required this.image,
80 this.imageErrorBuilder,
81 this.excludeFromSemantics = false,
82 this.imageSemanticLabel,
83 this.fadeOutDuration = const Duration(milliseconds: 300),
84 this.fadeOutCurve = Curves.easeOut,
85 this.fadeInDuration = const Duration(milliseconds: 700),
86 this.fadeInCurve = Curves.easeIn,
87 this.color,
88 this.colorBlendMode,
89 this.placeholderColor,
90 this.placeholderColorBlendMode,
91 this.width,
92 this.height,
93 this.fit,
94 this.placeholderFit,
95 this.filterQuality = FilterQuality.medium,
96 this.placeholderFilterQuality,
97 this.alignment = Alignment.center,
98 this.repeat = ImageRepeat.noRepeat,
99 this.matchTextDirection = false,
100 });
101
102 /// Creates a widget that uses a placeholder image stored in memory while
103 /// loading the final image from the network.
104 ///
105 /// The `placeholder` argument contains the bytes of the in-memory image.
106 ///
107 /// The `image` argument is the URL of the final image.
108 ///
109 /// The `placeholderScale` and `imageScale` arguments are passed to their
110 /// respective [ImageProvider]s (see also [ImageInfo.scale]).
111 ///
112 /// If [placeholderCacheWidth], [placeholderCacheHeight], [imageCacheWidth],
113 /// or [imageCacheHeight] are provided, it indicates to the
114 /// engine that the respective image should be decoded at the specified size.
115 /// The image will be rendered to the constraints of the layout or [width]
116 /// and [height] regardless of these parameters. These parameters are primarily
117 /// intended to reduce the memory usage of [ImageCache].
118 ///
119 /// The [placeholder], [image], [placeholderScale], [imageScale],
120 /// [fadeOutDuration], [fadeOutCurve], [fadeInDuration], [fadeInCurve],
121 /// [alignment], [repeat], and [matchTextDirection] arguments must not be
122 /// null.
123 ///
124 /// See also:
125 ///
126 /// * [Image.memory], which has more details about loading images from
127 /// memory.
128 /// * [Image.network], which has more details about loading images from
129 /// the network.
130 FadeInImage.memoryNetwork({
131 super.key,
132 required Uint8List placeholder,
133 this.placeholderErrorBuilder,
134 required String image,
135 this.imageErrorBuilder,
136 double placeholderScale = 1.0,
137 double imageScale = 1.0,
138 this.excludeFromSemantics = false,
139 this.imageSemanticLabel,
140 this.fadeOutDuration = const Duration(milliseconds: 300),
141 this.fadeOutCurve = Curves.easeOut,
142 this.fadeInDuration = const Duration(milliseconds: 700),
143 this.fadeInCurve = Curves.easeIn,
144 this.width,
145 this.height,
146 this.fit,
147 this.color,
148 this.colorBlendMode,
149 this.placeholderColor,
150 this.placeholderColorBlendMode,
151 this.placeholderFit,
152 this.filterQuality = FilterQuality.medium,
153 this.placeholderFilterQuality,
154 this.alignment = Alignment.center,
155 this.repeat = ImageRepeat.noRepeat,
156 this.matchTextDirection = false,
157 int? placeholderCacheWidth,
158 int? placeholderCacheHeight,
159 int? imageCacheWidth,
160 int? imageCacheHeight,
161 }) : placeholder = ResizeImage.resizeIfNeeded(
162 placeholderCacheWidth,
163 placeholderCacheHeight,
164 MemoryImage(placeholder, scale: placeholderScale),
165 ),
166 image = ResizeImage.resizeIfNeeded(
167 imageCacheWidth,
168 imageCacheHeight,
169 NetworkImage(image, scale: imageScale),
170 );
171
172 /// Creates a widget that uses a placeholder image stored in an asset bundle
173 /// while loading the final image from the network.
174 ///
175 /// The `placeholder` argument is the key of the image in the asset bundle.
176 ///
177 /// The `image` argument is the URL of the final image.
178 ///
179 /// The `placeholderScale` and `imageScale` arguments are passed to their
180 /// respective [ImageProvider]s (see also [ImageInfo.scale]).
181 ///
182 /// If `placeholderScale` is omitted or is null, pixel-density-aware asset
183 /// resolution will be attempted for the [placeholder] image. Otherwise, the
184 /// exact asset specified will be used.
185 ///
186 /// If [placeholderCacheWidth], [placeholderCacheHeight], [imageCacheWidth],
187 /// or [imageCacheHeight] are provided, it indicates to the
188 /// engine that the respective image should be decoded at the specified size.
189 /// The image will be rendered to the constraints of the layout or [width]
190 /// and [height] regardless of these parameters. These parameters are primarily
191 /// intended to reduce the memory usage of [ImageCache].
192 ///
193 /// See also:
194 ///
195 /// * [Image.asset], which has more details about loading images from
196 /// asset bundles.
197 /// * [Image.network], which has more details about loading images from
198 /// the network.
199 FadeInImage.assetNetwork({
200 super.key,
201 required String placeholder,
202 this.placeholderErrorBuilder,
203 required String image,
204 this.imageErrorBuilder,
205 AssetBundle? bundle,
206 double? placeholderScale,
207 double imageScale = 1.0,
208 this.excludeFromSemantics = false,
209 this.imageSemanticLabel,
210 this.fadeOutDuration = const Duration(milliseconds: 300),
211 this.fadeOutCurve = Curves.easeOut,
212 this.fadeInDuration = const Duration(milliseconds: 700),
213 this.fadeInCurve = Curves.easeIn,
214 this.width,
215 this.height,
216 this.fit,
217 this.color,
218 this.colorBlendMode,
219 this.placeholderColor,
220 this.placeholderColorBlendMode,
221 this.placeholderFit,
222 this.filterQuality = FilterQuality.medium,
223 this.placeholderFilterQuality,
224 this.alignment = Alignment.center,
225 this.repeat = ImageRepeat.noRepeat,
226 this.matchTextDirection = false,
227 int? placeholderCacheWidth,
228 int? placeholderCacheHeight,
229 int? imageCacheWidth,
230 int? imageCacheHeight,
231 }) : placeholder = placeholderScale != null
232 ? ResizeImage.resizeIfNeeded(
233 placeholderCacheWidth,
234 placeholderCacheHeight,
235 ExactAssetImage(placeholder, bundle: bundle, scale: placeholderScale),
236 )
237 : ResizeImage.resizeIfNeeded(
238 placeholderCacheWidth,
239 placeholderCacheHeight,
240 AssetImage(placeholder, bundle: bundle),
241 ),
242 image = ResizeImage.resizeIfNeeded(
243 imageCacheWidth,
244 imageCacheHeight,
245 NetworkImage(image, scale: imageScale),
246 );
247
248 /// Image displayed while the target [image] is loading.
249 final ImageProvider placeholder;
250
251 /// A builder function that is called if an error occurs during placeholder
252 /// image loading.
253 ///
254 /// If this builder is not provided, any exceptions will be reported to
255 /// [FlutterError.onError]. If it is provided, the caller should either handle
256 /// the exception by providing a replacement widget, or rethrow the exception.
257 final ImageErrorWidgetBuilder? placeholderErrorBuilder;
258
259 /// The target image that is displayed once it has loaded.
260 final ImageProvider image;
261
262 /// A builder function that is called if an error occurs during image loading.
263 ///
264 /// If this builder is not provided, any exceptions will be reported to
265 /// [FlutterError.onError]. If it is provided, the caller should either handle
266 /// the exception by providing a replacement widget, or rethrow the exception.
267 final ImageErrorWidgetBuilder? imageErrorBuilder;
268
269 /// The duration of the fade-out animation for the [placeholder].
270 final Duration fadeOutDuration;
271
272 /// The curve of the fade-out animation for the [placeholder].
273 final Curve fadeOutCurve;
274
275 /// The duration of the fade-in animation for the [image].
276 final Duration fadeInDuration;
277
278 /// The curve of the fade-in animation for the [image].
279 final Curve fadeInCurve;
280
281 /// If non-null, require the image to have this width.
282 ///
283 /// If null, the image will pick a size that best preserves its intrinsic
284 /// aspect ratio. This may result in a sudden change if the size of the
285 /// placeholder image does not match that of the target image. The size is
286 /// also affected by the scale factor.
287 final double? width;
288
289 /// If non-null, this color is blended with each image pixel using [colorBlendMode].
290 ///
291 /// Color applies to the [image].
292 ///
293 /// See Also:
294 ///
295 /// * [placeholderColor], the color which applies to the [placeholder].
296 final Color? color;
297
298 /// Used to combine [color] with this [image].
299 ///
300 /// The default is [BlendMode.srcIn]. In terms of the blend mode, [color] is
301 /// the source and this image is the destination.
302 ///
303 /// See also:
304 ///
305 /// * [BlendMode], which includes an illustration of the effect of each blend mode.
306 /// * [placeholderColorBlendMode], the color blend mode which applies to the [placeholder].
307 final BlendMode? colorBlendMode;
308
309 /// If non-null, this color is blended with each placeholder image pixel using [placeholderColorBlendMode].
310 ///
311 /// Color applies to the [placeholder].
312 ///
313 /// See Also:
314 ///
315 /// * [color], the color which applies to the [image].
316 final Color? placeholderColor;
317
318 /// Used to combine [placeholderColor] with the [placeholder] image.
319 ///
320 /// The default is [BlendMode.srcIn]. In terms of the blend mode, [placeholderColor] is
321 /// the source and this placeholder is the destination.
322 ///
323 /// See also:
324 ///
325 /// * [BlendMode], which includes an illustration of the effect of each blend mode.
326 /// * [colorBlendMode], the color blend mode which applies to the [image].
327 final BlendMode? placeholderColorBlendMode;
328
329 /// If non-null, require the image to have this height.
330 ///
331 /// If null, the image will pick a size that best preserves its intrinsic
332 /// aspect ratio. This may result in a sudden change if the size of the
333 /// placeholder image does not match that of the target image. The size is
334 /// also affected by the scale factor.
335 final double? height;
336
337 /// How to inscribe the image into the space allocated during layout.
338 ///
339 /// The default varies based on the other fields. See the discussion at
340 /// [paintImage].
341 final BoxFit? fit;
342
343 /// How to inscribe the placeholder image into the space allocated during layout.
344 ///
345 /// If not value set, it will fallback to [fit].
346 final BoxFit? placeholderFit;
347
348 /// The rendering quality of the image.
349 ///
350 /// {@macro flutter.widgets.image.filterQuality}
351 final FilterQuality filterQuality;
352
353 /// The rendering quality of the placeholder image.
354 ///
355 /// {@macro flutter.widgets.image.filterQuality}
356 final FilterQuality? placeholderFilterQuality;
357
358 /// How to align the image within its bounds.
359 ///
360 /// The alignment aligns the given position in the image to the given position
361 /// in the layout bounds. For example, an [Alignment] alignment of (-1.0,
362 /// -1.0) aligns the image to the top-left corner of its layout bounds, while an
363 /// [Alignment] alignment of (1.0, 1.0) aligns the bottom right of the
364 /// image with the bottom right corner of its layout bounds. Similarly, an
365 /// alignment of (0.0, 1.0) aligns the bottom middle of the image with the
366 /// middle of the bottom edge of its layout bounds.
367 ///
368 /// If the [alignment] is [TextDirection]-dependent (i.e. if it is a
369 /// [AlignmentDirectional]), then an ambient [Directionality] widget
370 /// must be in scope.
371 ///
372 /// Defaults to [Alignment.center].
373 ///
374 /// See also:
375 ///
376 /// * [Alignment], a class with convenient constants typically used to
377 /// specify an [AlignmentGeometry].
378 /// * [AlignmentDirectional], like [Alignment] for specifying alignments
379 /// relative to text direction.
380 final AlignmentGeometry alignment;
381
382 /// How to paint any portions of the layout bounds not covered by the image.
383 final ImageRepeat repeat;
384
385 /// Whether to paint the image in the direction of the [TextDirection].
386 ///
387 /// If this is true, then in [TextDirection.ltr] contexts, the image will be
388 /// drawn with its origin in the top left (the "normal" painting direction for
389 /// images); and in [TextDirection.rtl] contexts, the image will be drawn with
390 /// a scaling factor of -1 in the horizontal direction so that the origin is
391 /// in the top right.
392 ///
393 /// This is occasionally used with images in right-to-left environments, for
394 /// images that were designed for left-to-right locales. Be careful, when
395 /// using this, to not flip images with integral shadows, text, or other
396 /// effects that will look incorrect when flipped.
397 ///
398 /// If this is true, there must be an ambient [Directionality] widget in
399 /// scope.
400 final bool matchTextDirection;
401
402 /// Whether to exclude this image from semantics.
403 ///
404 /// This is useful for images which do not contribute meaningful information
405 /// to an application.
406 final bool excludeFromSemantics;
407
408 /// A semantic description of the [image].
409 ///
410 /// Used to provide a description of the [image] to TalkBack on Android, and
411 /// VoiceOver on iOS.
412 ///
413 /// This description will be used both while the [placeholder] is shown and
414 /// once the image has loaded.
415 final String? imageSemanticLabel;
416
417 @override
418 State<FadeInImage> createState() => _FadeInImageState();
419}
420
421class _FadeInImageState extends State<FadeInImage> {
422 static const Animation<double> _kOpaqueAnimation = AlwaysStoppedAnimation<double>(1.0);
423 bool targetLoaded = false;
424
425 // These ProxyAnimations are changed to the fade in animation by
426 // [_AnimatedFadeOutFadeInState]. Otherwise these animations are reset to
427 // their defaults by [_resetAnimations].
428 final ProxyAnimation _imageAnimation = ProxyAnimation(_kOpaqueAnimation);
429 final ProxyAnimation _placeholderAnimation = ProxyAnimation(_kOpaqueAnimation);
430
431 Image _image({
432 required ImageProvider image,
433 ImageErrorWidgetBuilder? errorBuilder,
434 ImageFrameBuilder? frameBuilder,
435 BoxFit? fit,
436 Color? color,
437 BlendMode? colorBlendMode,
438 required FilterQuality filterQuality,
439 required Animation<double> opacity,
440 }) {
441 return Image(
442 image: image,
443 errorBuilder: errorBuilder,
444 frameBuilder: frameBuilder,
445 opacity: opacity,
446 width: widget.width,
447 height: widget.height,
448 fit: fit,
449 color: color,
450 colorBlendMode: colorBlendMode,
451 filterQuality: filterQuality,
452 alignment: widget.alignment,
453 repeat: widget.repeat,
454 matchTextDirection: widget.matchTextDirection,
455 gaplessPlayback: true,
456 excludeFromSemantics: true,
457 );
458 }
459
460 @override
461 Widget build(BuildContext context) {
462 Widget result = _image(
463 image: widget.image,
464 errorBuilder: widget.imageErrorBuilder,
465 opacity: _imageAnimation,
466 fit: widget.fit,
467 color: widget.color,
468 colorBlendMode: widget.colorBlendMode,
469 filterQuality: widget.filterQuality,
470 frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
471 if (wasSynchronouslyLoaded || frame != null) {
472 targetLoaded = true;
473 }
474 return _AnimatedFadeOutFadeIn(
475 target: child,
476 targetProxyAnimation: _imageAnimation,
477 placeholder: _image(
478 image: widget.placeholder,
479 errorBuilder: widget.placeholderErrorBuilder,
480 opacity: _placeholderAnimation,
481 color: widget.placeholderColor,
482 colorBlendMode: widget.placeholderColorBlendMode,
483 fit: widget.placeholderFit ?? widget.fit,
484 filterQuality: widget.placeholderFilterQuality ?? widget.filterQuality,
485 ),
486 placeholderProxyAnimation: _placeholderAnimation,
487 isTargetLoaded: targetLoaded,
488 wasSynchronouslyLoaded: wasSynchronouslyLoaded,
489 fadeInDuration: widget.fadeInDuration,
490 fadeOutDuration: widget.fadeOutDuration,
491 fadeInCurve: widget.fadeInCurve,
492 fadeOutCurve: widget.fadeOutCurve,
493 );
494 },
495 );
496
497 if (!widget.excludeFromSemantics) {
498 result = Semantics(
499 container: widget.imageSemanticLabel != null,
500 image: true,
501 label: widget.imageSemanticLabel ?? '',
502 child: result,
503 );
504 }
505
506 return result;
507 }
508}
509
510class _AnimatedFadeOutFadeIn extends ImplicitlyAnimatedWidget {
511 const _AnimatedFadeOutFadeIn({
512 required this.target,
513 required this.targetProxyAnimation,
514 required this.placeholder,
515 required this.placeholderProxyAnimation,
516 required this.isTargetLoaded,
517 required this.fadeOutDuration,
518 required this.fadeOutCurve,
519 required this.fadeInDuration,
520 required this.fadeInCurve,
521 required this.wasSynchronouslyLoaded,
522 }) : assert(!wasSynchronouslyLoaded || isTargetLoaded),
523 super(duration: fadeInDuration + fadeOutDuration);
524
525 final Widget target;
526 final ProxyAnimation targetProxyAnimation;
527 final Widget placeholder;
528 final ProxyAnimation placeholderProxyAnimation;
529 final bool isTargetLoaded;
530 final Duration fadeInDuration;
531 final Duration fadeOutDuration;
532 final Curve fadeInCurve;
533 final Curve fadeOutCurve;
534 final bool wasSynchronouslyLoaded;
535
536 @override
537 _AnimatedFadeOutFadeInState createState() => _AnimatedFadeOutFadeInState();
538}
539
540class _AnimatedFadeOutFadeInState extends ImplicitlyAnimatedWidgetState<_AnimatedFadeOutFadeIn> {
541 Tween<double>? _targetOpacity;
542 Tween<double>? _placeholderOpacity;
543 Animation<double>? _targetOpacityAnimation;
544 Animation<double>? _placeholderOpacityAnimation;
545
546 @override
547 void forEachTween(TweenVisitor<dynamic> visitor) {
548 _targetOpacity =
549 visitor(
550 _targetOpacity,
551 widget.isTargetLoaded ? 1.0 : 0.0,
552 (dynamic value) => Tween<double>(begin: value as double),
553 )
554 as Tween<double>?;
555 _placeholderOpacity =
556 visitor(
557 _placeholderOpacity,
558 widget.isTargetLoaded ? 0.0 : 1.0,
559 (dynamic value) => Tween<double>(begin: value as double),
560 )
561 as Tween<double>?;
562 }
563
564 @override
565 void didUpdateTweens() {
566 if (widget.wasSynchronouslyLoaded) {
567 // Opacity animations should not be reset if image was synchronously loaded.
568 return;
569 }
570
571 _placeholderOpacityAnimation =
572 animation.drive(
573 TweenSequence<double>(<TweenSequenceItem<double>>[
574 TweenSequenceItem<double>(
575 tween: _placeholderOpacity!.chain(CurveTween(curve: widget.fadeOutCurve)),
576 weight: widget.fadeOutDuration.inMilliseconds.toDouble(),
577 ),
578 TweenSequenceItem<double>(
579 tween: ConstantTween<double>(0),
580 weight: widget.fadeInDuration.inMilliseconds.toDouble(),
581 ),
582 ]),
583 )..addStatusListener((AnimationStatus status) {
584 if (_placeholderOpacityAnimation!.isCompleted) {
585 // Need to rebuild to remove placeholder now that it is invisible.
586 setState(() {});
587 }
588 });
589
590 _targetOpacityAnimation = animation.drive(
591 TweenSequence<double>(<TweenSequenceItem<double>>[
592 TweenSequenceItem<double>(
593 tween: ConstantTween<double>(0),
594 weight: widget.fadeOutDuration.inMilliseconds.toDouble(),
595 ),
596 TweenSequenceItem<double>(
597 tween: _targetOpacity!.chain(CurveTween(curve: widget.fadeInCurve)),
598 weight: widget.fadeInDuration.inMilliseconds.toDouble(),
599 ),
600 ]),
601 );
602
603 widget.targetProxyAnimation.parent = _targetOpacityAnimation;
604 widget.placeholderProxyAnimation.parent = _placeholderOpacityAnimation;
605 }
606
607 @override
608 Widget build(BuildContext context) {
609 if (widget.wasSynchronouslyLoaded || (_placeholderOpacityAnimation?.isCompleted ?? true)) {
610 return widget.target;
611 }
612
613 return Stack(
614 fit: StackFit.passthrough,
615 alignment: AlignmentDirectional.center,
616 // Text direction is irrelevant here since we're using center alignment,
617 // but it allows the Stack to avoid a call to Directionality.of()
618 textDirection: TextDirection.ltr,
619 children: <Widget>[widget.target, widget.placeholder],
620 );
621 }
622
623 @override
624 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
625 super.debugFillProperties(properties);
626 properties.add(
627 DiagnosticsProperty<Animation<double>>('targetOpacity', _targetOpacityAnimation),
628 );
629 properties.add(
630 DiagnosticsProperty<Animation<double>>('placeholderOpacity', _placeholderOpacityAnimation),
631 );
632 }
633}
634