Commit 89d06450 authored by Yegor's avatar Yegor Committed by GitHub

FadeInImage: shows a placeholder while loading then fades in (#11371)

* FadeInImage: shows a placeholder while loading then fades in

* fix dartdoc quotes

* license headers; imports

* use ImageProvider; docs; constructors

* _resolveImage when placeholder changes

* address comments

* docs re ImageProvider changes; unsubscribe from placeholder

* rebase

* address comments
parent c72381aa
......@@ -14,7 +14,7 @@ export 'package:flutter/painting.dart' show
/// An image in the render tree.
///
/// The render image attempts to find a size for itself that fits in the given
/// constraints and preserves the image's intrinisc aspect ratio.
/// constraints and preserves the image's intrinsic aspect ratio.
///
/// The image is painted using [paintImage], which describes the meanings of the
/// various fields on this class in more detail.
......
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'basic.dart';
import 'framework.dart';
import 'image.dart';
import 'ticker_provider.dart';
/// An image that shows a [placeholder] image while the target [image] is
/// loading, then fades in the new image when it loads.
///
/// Use this class to display long-loading images, such as [new NetworkImage],
/// so that the image appears on screen with a graceful animation rather than
/// abruptly pops onto the screen.
///
/// If the [image] emits an [ImageInfo] synchronously, such as when the image
/// has been loaded and cached, the [image] is displayed immediately and the
/// [placeholder] is never displayed.
///
/// [fadeOutDuration] and [fadeOutCurve] control the fade-out animation of the
/// placeholder.
///
/// [fadeInDuration] and [fadeInCurve] control the fade-in animation of the
/// target [image].
///
/// Prefer a [placeholder] that's already cached so that it is displayed in one
/// frame. This prevents it from popping onto the screen.
///
/// When [image] changes it is resolved to a new [ImageStream]. If the new
/// [ImageStream.key] is different this widget subscribes to the new stream and
/// replaces the displayed image with images emitted by the new stream.
///
/// When [placeholder] changes and the [image] has not yet emitted an
/// [ImageInfo], then [placeholder] is resolved to a new [ImageStream]. If the
/// new [ImageStream.key] is different this widget subscribes to the new stream
/// and replaces the displayed image to images emitted by the new stream.
///
/// When either [placeholder] or [image] changes, this widget continues showing
/// the previously loaded image (if any) until the new image provider provides a
/// different image. This is known as "gapless playback" (see also
/// [Image.gaplessPlayback]).
///
/// ## Sample code:
///
/// ```dart
/// return new FadeInImage(
/// // here `bytes` is a Uint8List containing the bytes for the in-memory image
/// placeholder: new MemoryImage(bytes),
/// image: new NetworkImage('https://backend.example.com/image.png'),
/// );
/// ```
class FadeInImage extends StatefulWidget {
/// Creates a widget that displays a [placeholder] while an [image] is loading
/// then cross-fades to display the [image].
///
/// The [placeholder], [image], [fadeOutDuration], [fadeOutCurve],
/// [fadeInDuration], [fadeInCurve] and [repeat] arguments must not be null.
const FadeInImage({
Key key,
@required this.placeholder,
@required this.image,
this.fadeOutDuration: const Duration(milliseconds: 300),
this.fadeOutCurve: Curves.easeOut,
this.fadeInDuration: const Duration(milliseconds: 700),
this.fadeInCurve: Curves.easeIn,
this.width,
this.height,
this.fit,
this.alignment,
this.repeat: ImageRepeat.noRepeat,
}) : assert(placeholder != null),
assert(image != null),
assert(fadeOutDuration != null),
assert(fadeOutCurve != null),
assert(fadeInDuration != null),
assert(fadeInCurve != null),
assert(repeat != null),
super(key: key);
/// Creates a widget that uses a placeholder image stored in memory while
/// loading the final image from the network.
///
/// [placeholder] contains the bytes of the in-memory image.
///
/// [image] is the URL of the final image.
///
/// [placeholderScale] and [imageScale] are passed to their respective
/// [ImageProvider]s (see also [ImageInfo.scale]).
///
/// The [placeholder], [image], [placeholderScale], [imageScale],
/// [fadeOutDuration], [fadeOutCurve], [fadeInDuration], [fadeInCurve] and
/// [repeat] arguments must not be null.
///
/// See also:
///
/// * [new Image.memory], which has more details about loading images from
/// memory.
/// * [new Image.network], which has more details about loading images from
/// the network.
FadeInImage.memoryNetwork({
Key key,
@required Uint8List placeholder,
@required String image,
double placeholderScale: 1.0,
double imageScale: 1.0,
this.fadeOutDuration: const Duration(milliseconds: 300),
this.fadeOutCurve: Curves.easeOut,
this.fadeInDuration: const Duration(milliseconds: 700),
this.fadeInCurve: Curves.easeIn,
this.width,
this.height,
this.fit,
this.alignment,
this.repeat: ImageRepeat.noRepeat,
}) : assert(placeholder != null),
assert(image != null),
assert(placeholderScale != null),
assert(imageScale != null),
assert(fadeOutDuration != null),
assert(fadeOutCurve != null),
assert(fadeInDuration != null),
assert(fadeInCurve != null),
assert(repeat != null),
placeholder = new MemoryImage(placeholder, scale: placeholderScale),
image = new NetworkImage(image, scale: imageScale),
super(key: key);
/// Creates a widget that uses a placeholder image stored in an asset bundle
/// while loading the final image from the network.
///
/// [placeholder] is the key of the image in the asset bundle.
///
/// [image] is the URL of the final image.
///
/// [placeholderScale] and [imageScale] are passed to their respective
/// [ImageProvider]s (see also [ImageInfo.scale]).
///
/// If [placeholderScale] is omitted or is null, the pixel-density-aware asset
/// resolution will be attempted for the [placeholder] image. Otherwise, the
/// exact asset specified will be used.
///
/// The [placeholder], [image], [imageScale], [fadeOutDuration],
/// [fadeOutCurve], [fadeInDuration], [fadeInCurve] and [repeat] arguments
/// must not be null.
///
/// See also:
///
/// * [new Image.asset], which has more details about loading images from
/// asset bundles.
/// * [new Image.network], which has more details about loading images from
/// the network.
FadeInImage.assetNetwork({
Key key,
@required String placeholder,
@required String image,
AssetBundle bundle,
double placeholderScale,
double imageScale: 1.0,
this.fadeOutDuration: const Duration(milliseconds: 300),
this.fadeOutCurve: Curves.easeOut,
this.fadeInDuration: const Duration(milliseconds: 700),
this.fadeInCurve: Curves.easeIn,
this.width,
this.height,
this.fit,
this.alignment,
this.repeat: ImageRepeat.noRepeat,
}) : assert(placeholder != null),
assert(image != null),
placeholder = placeholderScale != null
? new ExactAssetImage(placeholder, bundle: bundle, scale: placeholderScale)
: new AssetImage(placeholder, bundle: bundle),
assert(imageScale != null),
assert(fadeOutDuration != null),
assert(fadeOutCurve != null),
assert(fadeInDuration != null),
assert(fadeInCurve != null),
assert(repeat != null),
image = new NetworkImage(image, scale: imageScale),
super(key: key);
/// Image displayed while the target [image] is loading.
final ImageProvider placeholder;
/// The target image that is displayed.
final ImageProvider image;
/// The duration of the fade-out animation for the [placeholder].
final Duration fadeOutDuration;
/// The curve of the fade-out animation for the [placeholder].
final Curve fadeOutCurve;
/// The duration of the fade-in animation for the [image].
final Duration fadeInDuration;
/// The curve of the fade-in animation for the [image].
final Curve fadeInCurve;
/// If non-null, require the image to have this width.
///
/// If null, the image will pick a size that best preserves its intrinsic
/// aspect ratio. This may result in a sudden change if the size of the
/// placeholder image does not match that of the target image. The size is
/// also affected by the scale factor.
final double width;
/// If non-null, require the image to have this height.
///
/// If null, the image will pick a size that best preserves its intrinsic
/// aspect ratio. This may result in a sudden change if the size of the
/// placeholder image does not match that of the target image. The size is
/// also affected by the scale factor.
final double height;
/// How to inscribe the image into the space allocated during layout.
///
/// The default varies based on the other fields. See the discussion at
/// [paintImage].
final BoxFit fit;
/// How to align the image within its bounds.
///
/// An alignment of (0.0, 0.0) aligns the image to the top-left corner of its
/// layout bounds. An alignment of (1.0, 0.5) aligns the image to the middle
/// of the right edge of its layout bounds.
final FractionalOffset alignment;
/// How to paint any portions of the layout bounds not covered by the image.
final ImageRepeat repeat;
@override
State<StatefulWidget> createState() => new _FadeInImageState();
}
/// The phases a [FadeInImage] goes through.
@visibleForTesting
enum FadeInImagePhase {
/// The initial state.
///
/// We do not yet know whether the target image is ready and therefore no
/// animation is necessary, or whether we need to use the placeholder and
/// wait for the image to load.
start,
/// Waiting for the target image to load.
waiting,
/// Fading out previous image.
fadeOut,
/// Fading in new image.
fadeIn,
/// Fade-in complete.
completed,
}
typedef void _ImageProviderResolverListener();
class _ImageProviderResolver {
_ImageProviderResolver({
@required this.state,
@required this.listener,
});
final _FadeInImageState state;
final _ImageProviderResolverListener listener;
FadeInImage get widget => state.widget;
ImageStream _imageStream;
ImageInfo _imageInfo;
void resolve(ImageProvider provider) {
final ImageStream oldImageStream = _imageStream;
_imageStream = provider.resolve(createLocalImageConfiguration(
state.context,
size: widget.width != null && widget.height != null ? new Size(widget.width, widget.height) : null
));
assert(_imageStream != null);
if (_imageStream.key != oldImageStream?.key) {
oldImageStream?.removeListener(_handleImageChanged);
_imageStream.addListener(_handleImageChanged);
}
}
void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) {
_imageInfo = imageInfo;
listener();
}
void stopListening() {
_imageStream?.removeListener(_handleImageChanged);
}
}
class _FadeInImageState extends State<FadeInImage> with TickerProviderStateMixin {
_ImageProviderResolver _imageResolver;
_ImageProviderResolver _placeholderResolver;
AnimationController _controller;
Animation<double> _animation;
FadeInImagePhase _phase = FadeInImagePhase.start;
FadeInImagePhase get phase => _phase;
@override
void initState() {
_imageResolver = new _ImageProviderResolver(state: this, listener: _updatePhase);
_placeholderResolver = new _ImageProviderResolver(state: this, listener: () {
setState(() {
// Trigger rebuild to display the placeholder image
});
});
_controller = new AnimationController(
value: 1.0,
vsync: this,
);
_controller.addListener(() {
setState(() {
// Trigger rebuild to update opacity value.
});
});
_controller.addStatusListener((AnimationStatus status) {
_updatePhase();
});
super.initState();
}
@override
void didChangeDependencies() {
_resolveImage();
super.didChangeDependencies();
}
@override
void didUpdateWidget(FadeInImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.image != oldWidget.image || widget.placeholder != widget.placeholder)
_resolveImage();
}
@override
void reassemble() {
_resolveImage(); // in case the image cache was flushed
super.reassemble();
}
void _resolveImage() {
_imageResolver.resolve(widget.image);
// No need to resolve the placeholder if we are past the placeholder stage.
if (_isShowingPlaceholder)
_placeholderResolver.resolve(widget.placeholder);
if (_phase == FadeInImagePhase.start)
_updatePhase();
}
void _updatePhase() {
setState(() {
switch(_phase) {
case FadeInImagePhase.start:
if (_imageResolver._imageInfo != null)
_phase = FadeInImagePhase.completed;
else
_phase = FadeInImagePhase.waiting;
break;
case FadeInImagePhase.waiting:
if (_imageResolver._imageInfo != null) {
// Received image data. Begin placeholder fade-out.
_controller.duration = widget.fadeOutDuration;
_animation = new CurvedAnimation(
parent: _controller,
curve: widget.fadeOutCurve,
);
_phase = FadeInImagePhase.fadeOut;
_controller.reverse(from: 1.0);
}
break;
case FadeInImagePhase.fadeOut:
if (_controller.status == AnimationStatus.dismissed) {
// Done fading out placeholder. Begin target image fade-in.
_controller.duration = widget.fadeInDuration;
_animation = new CurvedAnimation(
parent: _controller,
curve: widget.fadeInCurve,
);
_phase = FadeInImagePhase.fadeIn;
_placeholderResolver.stopListening();
_controller.forward(from: 0.0);
}
break;
case FadeInImagePhase.fadeIn:
if (_controller.status == AnimationStatus.completed) {
// Done finding in new image.
_phase = FadeInImagePhase.completed;
}
break;
case FadeInImagePhase.completed:
// Nothing to do.
break;
}
});
}
@override
void dispose() {
_imageResolver.stopListening();
_placeholderResolver.stopListening();
_controller.dispose();
super.dispose();
}
bool get _isShowingPlaceholder {
assert(_phase != null);
switch (_phase) {
case FadeInImagePhase.start:
case FadeInImagePhase.waiting:
case FadeInImagePhase.fadeOut:
return true;
case FadeInImagePhase.fadeIn:
case FadeInImagePhase.completed:
return false;
}
return null;
}
ImageInfo get _imageInfo {
return _isShowingPlaceholder
? _placeholderResolver._imageInfo
: _imageResolver._imageInfo;
}
@override
Widget build(BuildContext context) {
assert(_phase != FadeInImagePhase.start);
final ImageInfo imageInfo = _imageInfo;
return new RawImage(
image: imageInfo?.image,
width: widget.width,
height: widget.height,
scale: imageInfo?.scale ?? 1.0,
color: new Color.fromRGBO(255, 255, 255, _animation?.value ?? 1.0),
colorBlendMode: BlendMode.modulate,
fit: widget.fit,
alignment: widget.alignment,
repeat: widget.repeat,
);
}
@override
void debugFillProperties(List<DiagnosticsNode> description) {
super.debugFillProperties(description);
description.add(new EnumProperty<FadeInImagePhase>('phase', _phase));
description.add(new DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
description.add(new DiagnosticsProperty<ImageStream>('image stream', _imageResolver._imageStream));
description.add(new DiagnosticsProperty<ImageStream>('placeholder stream', _placeholderResolver._imageStream));
}
}
......@@ -162,10 +162,11 @@ class Image extends StatefulWidget {
/// If the `bundle` argument is omitted or null, then the
/// [DefaultAssetBundle] will be used.
///
/// By default, the exact asset specified will be used. In addition:
/// By default, the pixel-density-aware asset resolution will be attempted. In
/// addition:
///
/// * If the `scale` argument is omitted or null, then pixel-density-aware
/// asset resolution will be attempted.
/// * If the `scale` argument is provided and is not null, then the exact
/// asset specified will be used.
//
// TODO(ianh): Implement the following (see ../services/image_resolution.dart):
// ///
......
......@@ -29,6 +29,7 @@ export 'src/widgets/debug.dart';
export 'src/widgets/dismissible.dart';
export 'src/widgets/drag_target.dart';
export 'src/widgets/editable_text.dart';
export 'src/widgets/fade_in_image.dart';
export 'src/widgets/focus_manager.dart';
export 'src/widgets/focus_scope.dart';
export 'src/widgets/form.dart';
......
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'image_data.dart';
class TestImageProvider extends ImageProvider<TestImageProvider> {
TestImageProvider(this.testImage);
final ui.Image testImage;
final Completer<ImageInfo> _completer = new Completer<ImageInfo>.sync();
ImageConfiguration configuration;
@override
Future<TestImageProvider> obtainKey(ImageConfiguration configuration) {
return new SynchronousFuture<TestImageProvider>(this);
}
@override
ImageStream resolve(ImageConfiguration config) {
configuration = config;
return super.resolve(configuration);
}
@override
ImageStreamCompleter load(TestImageProvider key) =>
new OneFrameImageStreamCompleter(_completer.future);
ImageInfo complete() {
final ImageInfo imageInfo = new ImageInfo(image: testImage);
_completer.complete(imageInfo);
return imageInfo;
}
@override
String toString() => '${describeIdentity(this)}()';
}
Future<ui.Image> createTestImage() {
final Completer<ui.Image> uiImage = new Completer<ui.Image>();
ui.decodeImageFromList(new Uint8List.fromList(kTransparentImage), uiImage.complete);
return uiImage.future;
}
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../services/image_test_utils.dart';
Future<Null> main() async {
// These must run outside test zone to complete
final ui.Image targetImage = await createTestImage();
final ui.Image placeholderImage = await createTestImage();
group('FadeInImage', () {
testWidgets('animates uncached image and shows cached image immediately', (WidgetTester tester) async {
// State type is private, hence using dynamic.
dynamic state() => tester.state(find.byType(FadeInImage));
RawImage displayedImage() => tester.widget(find.byType(RawImage));
// The placeholder is expected to be already loaded
final TestImageProvider placeholderProvider = new TestImageProvider(placeholderImage);
// Test case: long loading image
final TestImageProvider imageProvider = new TestImageProvider(targetImage);
await tester.pumpWidget(new FadeInImage(
placeholder: placeholderProvider,
image: imageProvider,
fadeOutDuration: const Duration(milliseconds: 50),
fadeInDuration: const Duration(milliseconds: 50),
));
expect(displayedImage().image, null); // image providers haven't completed yet
placeholderProvider.complete();
await tester.pump();
expect(displayedImage().image, same(placeholderImage)); // placeholder completed
expect(state().phase, FadeInImagePhase.waiting);
imageProvider.complete(); // load the image
expect(state().phase, FadeInImagePhase.fadeOut); // fade out placeholder
for (int i = 0; i < 7; i += 1) {
expect(displayedImage().image, same(placeholderImage));
await tester.pump(const Duration(milliseconds: 10));
}
expect(displayedImage().image, same(targetImage));
expect(state().phase, FadeInImagePhase.fadeIn); // fade in image
for (int i = 0; i < 6; i += 1) {
expect(displayedImage().image, same(targetImage));
await tester.pump(const Duration(milliseconds: 10));
}
expect(state().phase, FadeInImagePhase.completed); // done
expect(displayedImage().image, same(targetImage));
// Test case: re-use state object (didUpdateWidget)
final dynamic stateBeforeDidUpdateWidget = state();
await tester.pumpWidget(new FadeInImage(
placeholder: placeholderProvider,
image: imageProvider,
));
final dynamic stateAfterDidUpdateWidget = state();
expect(stateAfterDidUpdateWidget, same(stateBeforeDidUpdateWidget));
expect(stateAfterDidUpdateWidget.phase, FadeInImagePhase.completed); // completes immediately
expect(displayedImage().image, same(targetImage));
// Test case: new state object but cached image
final dynamic stateBeforeRecreate = state();
await tester.pumpWidget(new Container()); // clear widget tree to prevent state reuse
await tester.pumpWidget(new FadeInImage(
placeholder: placeholderProvider,
image: imageProvider,
));
expect(displayedImage().image, same(targetImage));
final dynamic stateAfterRecreate = state();
expect(stateAfterRecreate, isNot(same(stateBeforeRecreate)));
expect(stateAfterRecreate.phase, FadeInImagePhase.completed); // completes immediately
expect(displayedImage().image, same(targetImage));
});
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment