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.low,
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.low,
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(placeholderCacheWidth, placeholderCacheHeight, MemoryImage(placeholder, scale: placeholderScale)),
162 image = ResizeImage.resizeIfNeeded(imageCacheWidth, imageCacheHeight, NetworkImage(image, scale: imageScale));
163
164 /// Creates a widget that uses a placeholder image stored in an asset bundle
165 /// while loading the final image from the network.
166 ///
167 /// The `placeholder` argument is the key of the image in the asset bundle.
168 ///
169 /// The `image` argument is the URL of the final image.
170 ///
171 /// The `placeholderScale` and `imageScale` arguments are passed to their
172 /// respective [ImageProvider]s (see also [ImageInfo.scale]).
173 ///
174 /// If `placeholderScale` is omitted or is null, pixel-density-aware asset
175 /// resolution will be attempted for the [placeholder] image. Otherwise, the
176 /// exact asset specified will be used.
177 ///
178 /// If [placeholderCacheWidth], [placeholderCacheHeight], [imageCacheWidth],
179 /// or [imageCacheHeight] are provided, it indicates to the
180 /// engine that the respective image should be decoded at the specified size.
181 /// The image will be rendered to the constraints of the layout or [width]
182 /// and [height] regardless of these parameters. These parameters are primarily
183 /// intended to reduce the memory usage of [ImageCache].
184 ///
185 /// See also:
186 ///
187 /// * [Image.asset], which has more details about loading images from
188 /// asset bundles.
189 /// * [Image.network], which has more details about loading images from
190 /// the network.
191 FadeInImage.assetNetwork({
192 super.key,
193 required String placeholder,
194 this.placeholderErrorBuilder,
195 required String image,
196 this.imageErrorBuilder,
197 AssetBundle? bundle,
198 double? placeholderScale,
199 double imageScale = 1.0,
200 this.excludeFromSemantics = false,
201 this.imageSemanticLabel,
202 this.fadeOutDuration = const Duration(milliseconds: 300),
203 this.fadeOutCurve = Curves.easeOut,
204 this.fadeInDuration = const Duration(milliseconds: 700),
205 this.fadeInCurve = Curves.easeIn,
206 this.width,
207 this.height,
208 this.fit,
209 this.color,
210 this.colorBlendMode,
211 this.placeholderColor,
212 this.placeholderColorBlendMode,
213 this.placeholderFit,
214 this.filterQuality = FilterQuality.low,
215 this.placeholderFilterQuality,
216 this.alignment = Alignment.center,
217 this.repeat = ImageRepeat.noRepeat,
218 this.matchTextDirection = false,
219 int? placeholderCacheWidth,
220 int? placeholderCacheHeight,
221 int? imageCacheWidth,
222 int? imageCacheHeight,
223 }) : placeholder = placeholderScale != null
224 ? ResizeImage.resizeIfNeeded(placeholderCacheWidth, placeholderCacheHeight, ExactAssetImage(placeholder, bundle: bundle, scale: placeholderScale))
225 : ResizeImage.resizeIfNeeded(placeholderCacheWidth, placeholderCacheHeight, AssetImage(placeholder, bundle: bundle)),
226 image = ResizeImage.resizeIfNeeded(imageCacheWidth, imageCacheHeight, NetworkImage(image, scale: imageScale));
227
228 /// Image displayed while the target [image] is loading.
229 final ImageProvider placeholder;
230
231 /// A builder function that is called if an error occurs during placeholder
232 /// image loading.
233 ///
234 /// If this builder is not provided, any exceptions will be reported to
235 /// [FlutterError.onError]. If it is provided, the caller should either handle
236 /// the exception by providing a replacement widget, or rethrow the exception.
237 final ImageErrorWidgetBuilder? placeholderErrorBuilder;
238
239 /// The target image that is displayed once it has loaded.
240 final ImageProvider image;
241
242 /// A builder function that is called if an error occurs during image loading.
243 ///
244 /// If this builder is not provided, any exceptions will be reported to
245 /// [FlutterError.onError]. If it is provided, the caller should either handle
246 /// the exception by providing a replacement widget, or rethrow the exception.
247 final ImageErrorWidgetBuilder? imageErrorBuilder;
248
249 /// The duration of the fade-out animation for the [placeholder].
250 final Duration fadeOutDuration;
251
252 /// The curve of the fade-out animation for the [placeholder].
253 final Curve fadeOutCurve;
254
255 /// The duration of the fade-in animation for the [image].
256 final Duration fadeInDuration;
257
258 /// The curve of the fade-in animation for the [image].
259 final Curve fadeInCurve;
260
261 /// If non-null, require the image to have this width.
262 ///
263 /// If null, the image will pick a size that best preserves its intrinsic
264 /// aspect ratio. This may result in a sudden change if the size of the
265 /// placeholder image does not match that of the target image. The size is
266 /// also affected by the scale factor.
267 final double? width;
268
269 /// If non-null, this color is blended with each image pixel using [colorBlendMode].
270 ///
271 /// Color applies to the [image].
272 ///
273 /// See Also:
274 ///
275 /// * [placeholderColor], the color which applies to the [placeholder].
276 final Color? color;
277
278 /// Used to combine [color] with this [image].
279 ///
280 /// The default is [BlendMode.srcIn]. In terms of the blend mode, [color] is
281 /// the source and this image is the destination.
282 ///
283 /// See also:
284 ///
285 /// * [BlendMode], which includes an illustration of the effect of each blend mode.
286 /// * [placeholderColorBlendMode], the color blend mode which applies to the [placeholder].
287 final BlendMode? colorBlendMode;
288
289 /// If non-null, this color is blended with each placeholder image pixel using [placeholderColorBlendMode].
290 ///
291 /// Color applies to the [placeholder].
292 ///
293 /// See Also:
294 ///
295 /// * [color], the color which applies to the [image].
296 final Color? placeholderColor;
297
298 /// Used to combine [placeholderColor] with the [placeholder] image.
299 ///
300 /// The default is [BlendMode.srcIn]. In terms of the blend mode, [placeholderColor] is
301 /// the source and this placeholder is the destination.
302 ///
303 /// See also:
304 ///
305 /// * [BlendMode], which includes an illustration of the effect of each blend mode.
306 /// * [colorBlendMode], the color blend mode which applies to the [image].
307 final BlendMode? placeholderColorBlendMode;
308
309 /// If non-null, require the image to have this height.
310 ///
311 /// If null, the image will pick a size that best preserves its intrinsic
312 /// aspect ratio. This may result in a sudden change if the size of the
313 /// placeholder image does not match that of the target image. The size is
314 /// also affected by the scale factor.
315 final double? height;
316
317 /// How to inscribe the image into the space allocated during layout.
318 ///
319 /// The default varies based on the other fields. See the discussion at
320 /// [paintImage].
321 final BoxFit? fit;
322
323 /// How to inscribe the placeholder image into the space allocated during layout.
324 ///
325 /// If not value set, it will fallback to [fit].
326 final BoxFit? placeholderFit;
327
328 /// The rendering quality of the image.
329 ///
330 /// {@macro flutter.widgets.image.filterQuality}
331 final FilterQuality filterQuality;
332
333 /// The rendering quality of the placeholder image.
334 ///
335 /// {@macro flutter.widgets.image.filterQuality}
336 final FilterQuality? placeholderFilterQuality;
337
338 /// How to align the image within its bounds.
339 ///
340 /// The alignment aligns the given position in the image to the given position
341 /// in the layout bounds. For example, an [Alignment] alignment of (-1.0,
342 /// -1.0) aligns the image to the top-left corner of its layout bounds, while an
343 /// [Alignment] alignment of (1.0, 1.0) aligns the bottom right of the
344 /// image with the bottom right corner of its layout bounds. Similarly, an
345 /// alignment of (0.0, 1.0) aligns the bottom middle of the image with the
346 /// middle of the bottom edge of its layout bounds.
347 ///
348 /// If the [alignment] is [TextDirection]-dependent (i.e. if it is a
349 /// [AlignmentDirectional]), then an ambient [Directionality] widget
350 /// must be in scope.
351 ///
352 /// Defaults to [Alignment.center].
353 ///
354 /// See also:
355 ///
356 /// * [Alignment], a class with convenient constants typically used to
357 /// specify an [AlignmentGeometry].
358 /// * [AlignmentDirectional], like [Alignment] for specifying alignments
359 /// relative to text direction.
360 final AlignmentGeometry alignment;
361
362 /// How to paint any portions of the layout bounds not covered by the image.
363 final ImageRepeat repeat;
364
365 /// Whether to paint the image in the direction of the [TextDirection].
366 ///
367 /// If this is true, then in [TextDirection.ltr] contexts, the image will be
368 /// drawn with its origin in the top left (the "normal" painting direction for
369 /// images); and in [TextDirection.rtl] contexts, the image will be drawn with
370 /// a scaling factor of -1 in the horizontal direction so that the origin is
371 /// in the top right.
372 ///
373 /// This is occasionally used with images in right-to-left environments, for
374 /// images that were designed for left-to-right locales. Be careful, when
375 /// using this, to not flip images with integral shadows, text, or other
376 /// effects that will look incorrect when flipped.
377 ///
378 /// If this is true, there must be an ambient [Directionality] widget in
379 /// scope.
380 final bool matchTextDirection;
381
382 /// Whether to exclude this image from semantics.
383 ///
384 /// This is useful for images which do not contribute meaningful information
385 /// to an application.
386 final bool excludeFromSemantics;
387
388 /// A semantic description of the [image].
389 ///
390 /// Used to provide a description of the [image] to TalkBack on Android, and
391 /// VoiceOver on iOS.
392 ///
393 /// This description will be used both while the [placeholder] is shown and
394 /// once the image has loaded.
395 final String? imageSemanticLabel;
396
397 @override
398 State<FadeInImage> createState() => _FadeInImageState();
399}
400
401class _FadeInImageState extends State<FadeInImage> {
402 static const Animation<double> _kOpaqueAnimation = AlwaysStoppedAnimation<double>(1.0);
403 bool targetLoaded = false;
404
405 // These ProxyAnimations are changed to the fade in animation by
406 // [_AnimatedFadeOutFadeInState]. Otherwise these animations are reset to
407 // their defaults by [_resetAnimations].
408 final ProxyAnimation _imageAnimation = ProxyAnimation(_kOpaqueAnimation);
409 final ProxyAnimation _placeholderAnimation = ProxyAnimation(_kOpaqueAnimation);
410
411 Image _image({
412 required ImageProvider image,
413 ImageErrorWidgetBuilder? errorBuilder,
414 ImageFrameBuilder? frameBuilder,
415 BoxFit? fit,
416 Color? color,
417 BlendMode? colorBlendMode,
418 required FilterQuality filterQuality,
419 required Animation<double> opacity,
420 }) {
421 return Image(
422 image: image,
423 errorBuilder: errorBuilder,
424 frameBuilder: frameBuilder,
425 opacity: opacity,
426 width: widget.width,
427 height: widget.height,
428 fit: fit,
429 color: color,
430 colorBlendMode: colorBlendMode,
431 filterQuality: filterQuality,
432 alignment: widget.alignment,
433 repeat: widget.repeat,
434 matchTextDirection: widget.matchTextDirection,
435 gaplessPlayback: true,
436 excludeFromSemantics: true,
437 );
438 }
439
440 @override
441 Widget build(BuildContext context) {
442 Widget result = _image(
443 image: widget.image,
444 errorBuilder: widget.imageErrorBuilder,
445 opacity: _imageAnimation,
446 fit: widget.fit,
447 color: widget.color,
448 colorBlendMode: widget.colorBlendMode,
449 filterQuality: widget.filterQuality,
450 frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) {
451 if (wasSynchronouslyLoaded || frame != null) {
452 targetLoaded = true;
453 }
454 return _AnimatedFadeOutFadeIn(
455 target: child,
456 targetProxyAnimation: _imageAnimation,
457 placeholder: _image(
458 image: widget.placeholder,
459 errorBuilder: widget.placeholderErrorBuilder,
460 opacity: _placeholderAnimation,
461 color: widget.placeholderColor,
462 colorBlendMode: widget.placeholderColorBlendMode,
463 fit: widget.placeholderFit ?? widget.fit,
464 filterQuality: widget.placeholderFilterQuality ?? widget.filterQuality,
465 ),
466 placeholderProxyAnimation: _placeholderAnimation,
467 isTargetLoaded: targetLoaded,
468 wasSynchronouslyLoaded: wasSynchronouslyLoaded,
469 fadeInDuration: widget.fadeInDuration,
470 fadeOutDuration: widget.fadeOutDuration,
471 fadeInCurve: widget.fadeInCurve,
472 fadeOutCurve: widget.fadeOutCurve,
473 );
474 },
475 );
476
477 if (!widget.excludeFromSemantics) {
478 result = Semantics(
479 container: widget.imageSemanticLabel != null,
480 image: true,
481 label: widget.imageSemanticLabel ?? '',
482 child: result,
483 );
484 }
485
486 return result;
487 }
488}
489
490class _AnimatedFadeOutFadeIn extends ImplicitlyAnimatedWidget {
491 const _AnimatedFadeOutFadeIn({
492 required this.target,
493 required this.targetProxyAnimation,
494 required this.placeholder,
495 required this.placeholderProxyAnimation,
496 required this.isTargetLoaded,
497 required this.fadeOutDuration,
498 required this.fadeOutCurve,
499 required this.fadeInDuration,
500 required this.fadeInCurve,
501 required this.wasSynchronouslyLoaded,
502 }) : assert(!wasSynchronouslyLoaded || isTargetLoaded),
503 super(duration: fadeInDuration + fadeOutDuration);
504
505 final Widget target;
506 final ProxyAnimation targetProxyAnimation;
507 final Widget placeholder;
508 final ProxyAnimation placeholderProxyAnimation;
509 final bool isTargetLoaded;
510 final Duration fadeInDuration;
511 final Duration fadeOutDuration;
512 final Curve fadeInCurve;
513 final Curve fadeOutCurve;
514 final bool wasSynchronouslyLoaded;
515
516 @override
517 _AnimatedFadeOutFadeInState createState() => _AnimatedFadeOutFadeInState();
518}
519
520class _AnimatedFadeOutFadeInState extends ImplicitlyAnimatedWidgetState<_AnimatedFadeOutFadeIn> {
521 Tween<double>? _targetOpacity;
522 Tween<double>? _placeholderOpacity;
523 Animation<double>? _targetOpacityAnimation;
524 Animation<double>? _placeholderOpacityAnimation;
525
526 @override
527 void forEachTween(TweenVisitor<dynamic> visitor) {
528 _targetOpacity = visitor(
529 _targetOpacity,
530 widget.isTargetLoaded ? 1.0 : 0.0,
531 (dynamic value) => Tween<double>(begin: value as double),
532 ) as Tween<double>?;
533 _placeholderOpacity = visitor(
534 _placeholderOpacity,
535 widget.isTargetLoaded ? 0.0 : 1.0,
536 (dynamic value) => Tween<double>(begin: value as double),
537 ) as Tween<double>?;
538 }
539
540 @override
541 void didUpdateTweens() {
542 if (widget.wasSynchronouslyLoaded) {
543 // Opacity animations should not be reset if image was synchronously loaded.
544 return;
545 }
546
547 _placeholderOpacityAnimation = animation.drive(TweenSequence<double>(<TweenSequenceItem<double>>[
548 TweenSequenceItem<double>(
549 tween: _placeholderOpacity!.chain(CurveTween(curve: widget.fadeOutCurve)),
550 weight: widget.fadeOutDuration.inMilliseconds.toDouble(),
551 ),
552 TweenSequenceItem<double>(
553 tween: ConstantTween<double>(0),
554 weight: widget.fadeInDuration.inMilliseconds.toDouble(),
555 ),
556 ]))..addStatusListener((AnimationStatus status) {
557 if (_placeholderOpacityAnimation!.isCompleted) {
558 // Need to rebuild to remove placeholder now that it is invisible.
559 setState(() {});
560 }
561 });
562
563 _targetOpacityAnimation = animation.drive(TweenSequence<double>(<TweenSequenceItem<double>>[
564 TweenSequenceItem<double>(
565 tween: ConstantTween<double>(0),
566 weight: widget.fadeOutDuration.inMilliseconds.toDouble(),
567 ),
568 TweenSequenceItem<double>(
569 tween: _targetOpacity!.chain(CurveTween(curve: widget.fadeInCurve)),
570 weight: widget.fadeInDuration.inMilliseconds.toDouble(),
571 ),
572 ]));
573
574 widget.targetProxyAnimation.parent = _targetOpacityAnimation;
575 widget.placeholderProxyAnimation.parent = _placeholderOpacityAnimation;
576 }
577
578 @override
579 Widget build(BuildContext context) {
580 if (widget.wasSynchronouslyLoaded ||
581 (_placeholderOpacityAnimation?.isCompleted ?? true)) {
582 return widget.target;
583 }
584
585 return Stack(
586 fit: StackFit.passthrough,
587 alignment: AlignmentDirectional.center,
588 // Text direction is irrelevant here since we're using center alignment,
589 // but it allows the Stack to avoid a call to Directionality.of()
590 textDirection: TextDirection.ltr,
591 children: <Widget>[
592 widget.target,
593 widget.placeholder,
594 ],
595 );
596 }
597
598 @override
599 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
600 super.debugFillProperties(properties);
601 properties.add(DiagnosticsProperty<Animation<double>>('targetOpacity', _targetOpacityAnimation));
602 properties.add(DiagnosticsProperty<Animation<double>>('placeholderOpacity', _placeholderOpacityAnimation));
603 }
604}
605