Unverified Commit 68841469 authored by Todd Volkert's avatar Todd Volkert Committed by GitHub

Add loading support to Image (#33369)

This adds two new builders to the `Image` class:

* `frameBuilder`, which allows callers to control the widget
  created by an [Image].
* `loadingBuilder`, which allows callers fine-grained control
  over how to display loading progress of an image to the user.

`FadeInImage` can be simplified by migrating to the new API.
This is done in a follow-on commit.

https://github.com/flutter/flutter/issues/32374
parent 925f5f1c
...@@ -86,8 +86,3 @@ follows: ...@@ -86,8 +86,3 @@ follows:
- [`stateless_widget_scaffold`](stateless_widget_scaffold.tmpl) : Similar to - [`stateless_widget_scaffold`](stateless_widget_scaffold.tmpl) : Similar to
`stateless_widget_material`, except that it wraps the stateless widget with a `stateless_widget_material`, except that it wraps the stateless widget with a
Scaffold. Scaffold.
- [`stateful_widget_animation`](stateful_widget_animation.tmpl) : Similar to
`stateful_widget`, except that it declares an `AnimationController` instance
variable called `_controller`, and it properly initializes the controller
in `initState()` and properly disposes of the controller in `dispose()`.
// Flutter code sample for {{id}}
{{description}}
import 'package:flutter/widgets.dart';
{{code-imports}}
void main() => runApp(new MyApp());
/// This Widget is the main application widget.
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return WidgetsApp(
title: 'Flutter Code Sample',
home: MyStatefulWidget(),
color: const Color(0xffffffff),
);
}
}
{{code-preamble}}
/// This is the stateful widget that the main application instantiates.
class MyStatefulWidget extends StatefulWidget {
MyStatefulWidget({Key key}) : super(key: key);
@override
_MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}
// This is the private State class that goes with MyStatefulWidget.
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
{{code}}
}
...@@ -12,6 +12,7 @@ import 'package:flutter/services.dart'; ...@@ -12,6 +12,7 @@ import 'package:flutter/services.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
import 'basic.dart'; import 'basic.dart';
import 'binding.dart';
import 'framework.dart'; import 'framework.dart';
import 'localizations.dart'; import 'localizations.dart';
import 'media_query.dart'; import 'media_query.dart';
...@@ -108,6 +109,83 @@ Future<void> precacheImage( ...@@ -108,6 +109,83 @@ Future<void> precacheImage(
return completer.future; return completer.future;
} }
/// Signature used by [Image.frameBuilder] to control the widget that will be
/// used when an [Image] is built.
///
/// The `child` argument contains the default image widget and is guaranteed to
/// be non-null. Typically, this builder will wrap the `child` widget in some
/// way and return the wrapped widget. If this builder returns `child` directly,
/// it will yield the same result as if [Image.frameBuilder] was null.
///
/// The `frame` argument specifies the index of the current image frame being
/// rendered. It will be null before the first image frame is ready, and zero
/// for the first image frame. For single-frame images, it will never be greater
/// than zero. For multi-frame images (such as animated GIFs), it will increase
/// by one every time a new image frame is shown (including when the image
/// animates in a loop).
///
/// The `wasSynchronouslyLoaded` argument specifies whether the image was
/// available synchronously (on the same
/// [rendering pipeline frame](rendering/RendererBinding/drawFrame.html) as the
/// `Image` widget itself was created) and thus able to be painted immediately.
/// If this is false, then there was one or more rendering pipeline frames where
/// the image wasn't yet available to be painted. For multi-frame images (such
/// as animated GIFs), the value of this argument will be the same for all image
/// frames. In other words, if the first image frame was available immediately,
/// then this argument will be true for all image frames.
///
/// This builder must not return null.
///
/// See also:
///
/// * [Image.frameBuilder], which makes use of this signature in the [Image]
/// widget.
typedef ImageFrameBuilder = Widget Function(
BuildContext context,
Widget child,
int frame,
bool wasSynchronouslyLoaded,
);
/// Signature used by [Image.loadingBuilder] to build a representation of the
/// image's loading progress.
///
/// This is useful for images that are incrementally loaded (e.g. over a local
/// file system or a network), and the application wishes to give the user an
/// indication of when the image will be displayed.
///
/// The `child` argument contains the default image widget and is guaranteed to
/// be non-null. Typically, this builder will wrap the `child` widget in some
/// way and return the wrapped widget. If this builder returns `child` directly,
/// it will yield the same result as if [Image.loadingBuilder] was null.
///
/// The `loadingProgress` argument contains the current progress towards loading
/// the image. This argument will be non-null while the image is loading, but it
/// will be null in the following cases:
///
/// * When the widget is first rendered before any bytes have been loaded.
/// * When an image has been fully loaded and is available to be painted.
///
/// If callers are implementing custom [ImageProvider] and [ImageStream]
/// instances (which is very rare), it's possible to produce image streams that
/// continue to fire image chunk events after an image frame has been loaded.
/// In such cases, the `child` parameter will represent the current
/// fully-loaded image frame.
///
/// This builder must not return null.
///
/// See also:
///
/// * [Image.loadingBuilder], which makes use of this signature in the [Image]
/// widget.
/// * [ImageChunkListener], a lower-level signature for listening to raw
/// [ImageChunkEvent]s.
typedef ImageLoadingBuilder = Widget Function(
BuildContext context,
Widget child,
ImageChunkEvent loadingProgress,
);
/// A widget that displays an image. /// A widget that displays an image.
/// ///
/// Several constructors are provided for the various ways that an image can be /// Several constructors are provided for the various ways that an image can be
...@@ -160,6 +238,8 @@ class Image extends StatefulWidget { ...@@ -160,6 +238,8 @@ class Image extends StatefulWidget {
const Image({ const Image({
Key key, Key key,
@required this.image, @required this.image,
this.frameBuilder,
this.loadingBuilder,
this.semanticLabel, this.semanticLabel,
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
this.width, this.width,
...@@ -204,6 +284,8 @@ class Image extends StatefulWidget { ...@@ -204,6 +284,8 @@ class Image extends StatefulWidget {
String src, { String src, {
Key key, Key key,
double scale = 1.0, double scale = 1.0,
this.frameBuilder,
this.loadingBuilder,
this.semanticLabel, this.semanticLabel,
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
this.width, this.width,
...@@ -246,6 +328,7 @@ class Image extends StatefulWidget { ...@@ -246,6 +328,7 @@ class Image extends StatefulWidget {
File file, { File file, {
Key key, Key key,
double scale = 1.0, double scale = 1.0,
this.frameBuilder,
this.semanticLabel, this.semanticLabel,
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
this.width, this.width,
...@@ -260,6 +343,7 @@ class Image extends StatefulWidget { ...@@ -260,6 +343,7 @@ class Image extends StatefulWidget {
this.gaplessPlayback = false, this.gaplessPlayback = false,
this.filterQuality = FilterQuality.low, this.filterQuality = FilterQuality.low,
}) : image = FileImage(file, scale: scale), }) : image = FileImage(file, scale: scale),
loadingBuilder = null,
assert(alignment != null), assert(alignment != null),
assert(repeat != null), assert(repeat != null),
assert(filterQuality != null), assert(filterQuality != null),
...@@ -395,6 +479,7 @@ class Image extends StatefulWidget { ...@@ -395,6 +479,7 @@ class Image extends StatefulWidget {
String name, { String name, {
Key key, Key key,
AssetBundle bundle, AssetBundle bundle,
this.frameBuilder,
this.semanticLabel, this.semanticLabel,
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
double scale, double scale,
...@@ -413,6 +498,7 @@ class Image extends StatefulWidget { ...@@ -413,6 +498,7 @@ class Image extends StatefulWidget {
}) : image = scale != null }) : image = scale != null
? ExactAssetImage(name, bundle: bundle, scale: scale, package: package) ? ExactAssetImage(name, bundle: bundle, scale: scale, package: package)
: AssetImage(name, bundle: bundle, package: package), : AssetImage(name, bundle: bundle, package: package),
loadingBuilder = null,
assert(alignment != null), assert(alignment != null),
assert(repeat != null), assert(repeat != null),
assert(matchTextDirection != null), assert(matchTextDirection != null),
...@@ -437,6 +523,7 @@ class Image extends StatefulWidget { ...@@ -437,6 +523,7 @@ class Image extends StatefulWidget {
Uint8List bytes, { Uint8List bytes, {
Key key, Key key,
double scale = 1.0, double scale = 1.0,
this.frameBuilder,
this.semanticLabel, this.semanticLabel,
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
this.width, this.width,
...@@ -451,6 +538,7 @@ class Image extends StatefulWidget { ...@@ -451,6 +538,7 @@ class Image extends StatefulWidget {
this.gaplessPlayback = false, this.gaplessPlayback = false,
this.filterQuality = FilterQuality.low, this.filterQuality = FilterQuality.low,
}) : image = MemoryImage(bytes, scale: scale), }) : image = MemoryImage(bytes, scale: scale),
loadingBuilder = null,
assert(alignment != null), assert(alignment != null),
assert(repeat != null), assert(repeat != null),
assert(matchTextDirection != null), assert(matchTextDirection != null),
...@@ -459,6 +547,163 @@ class Image extends StatefulWidget { ...@@ -459,6 +547,163 @@ class Image extends StatefulWidget {
/// The image to display. /// The image to display.
final ImageProvider image; final ImageProvider image;
/// A builder function responsible for creating the widget that represents
/// this image.
///
/// If this is null, this widget will display an image that is painted as
/// soon as the first image frame is available (and will appear to "pop" in
/// if it becomes available asynchronously). Callers might use this builder to
/// add effects to the image (such as fading the image in when it becomes
/// available) or to display a placeholder widget while the image is loading.
///
/// To have finer-grained control over the way that an image's loading
/// progress is communicated to the user, see [loadingBuilder].
///
/// ## Chaining with [loadingBuilder]
///
/// If a [loadingBuilder] has _also_ been specified for an image, the two
/// builders will be chained together: the _result_ of this builder will
/// be passed as the `child` argument to the [loadingBuilder]. For example,
/// consider the following builders used in conjunction:
///
/// {@template flutter.widgets.image.chainedBuildersExample}
/// ```dart
/// Image(
/// ...
/// frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) {
/// return Padding(
/// padding: EdgeInsets.all(8.0),
/// child: child,
/// );
/// },
/// loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent loadingProgress) {
/// return Center(child: child);
/// },
/// )
/// ```
///
/// In this example, the widget hierarchy will contain the following:
///
/// ```dart
/// Center(
/// Padding(
/// padding: EdgeInsets.all(8.0),
/// child: <image>,
/// ),
/// )
/// ```
/// {@endtemplate}
///
/// {@tool snippet --template=stateless_widget_material}
///
/// The following sample demonstrates how to use this builder to implement an
/// image that fades in once it's been loaded.
///
/// This sample contains a limited subset of the functionality that the
/// [FadeInImage] widget provides out of the box.
///
/// ```dart
/// @override
/// Widget build(BuildContext context) {
/// return DecoratedBox(
/// decoration: BoxDecoration(
/// color: Colors.white,
/// border: Border.all(),
/// borderRadius: BorderRadius.circular(20),
/// ),
/// child: Image.network(
/// 'https://example.com/image.jpg',
/// frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) {
/// if (wasSynchronouslyLoaded) {
/// return child;
/// }
/// return AnimatedOpacity(
/// child: child,
/// opacity: frame == null ? 0 : 1,
/// duration: const Duration(seconds: 1),
/// curve: Curves.easeOut,
/// );
/// },
/// ),
/// );
/// }
/// ```
/// {@end-tool}
///
/// Run against a real-world image, the previous example renders the following
/// image.
///
/// {@animation 400 400 https://flutter.github.io/assets-for-api-docs/assets/widgets/frame_builder_image.mp4}
final ImageFrameBuilder frameBuilder;
/// A builder that specifies the widget to display to the user while an image
/// is still loading.
///
/// If this is null, and the image is loaded incrementally (e.g. over a
/// network), the user will receive no indication of the progress as the
/// bytes of the image are loaded.
///
/// For more information on how to interpret the arguments that are passed to
/// this builder, see the documentation on [ImageLoadingBuilder].
///
/// ## Performance implications
///
/// If a [loadingBuilder] is specified for an image, the [Image] widget is
/// likely to be rebuilt on every
/// [rendering pipeline frame](rendering/RendererBinding/drawFrame.html) until
/// the image has loaded. This is useful for cases such as displaying a loading
/// progress indicator, but for simpler cases such as displaying a placeholder
/// widget that doesn't depend on the loading progress (e.g. static "loading"
/// text), [frameBuilder] will likely work and not incur as much cost.
///
/// ## Chaining with [frameBuilder]
///
/// If a [frameBuilder] has _also_ been specified for an image, the two
/// builders will be chained together: the `child` argument to this
/// builder will contain the _result_ of the [frameBuilder]. For example,
/// consider the following builders used in conjunction:
///
/// {@macro flutter.widgets.image.chainedBuildersExample}
///
/// {@tool snippet --template=stateless_widget_material}
///
/// The following sample uses [loadingBuilder] to show a
/// [CircularProgressIndicator] while an image loads over the network.
///
/// ```dart
/// Widget build(BuildContext context) {
/// return DecoratedBox(
/// decoration: BoxDecoration(
/// color: Colors.white,
/// border: Border.all(),
/// borderRadius: BorderRadius.circular(20),
/// ),
/// child: Image.network(
/// 'https://example.com/image.jpg',
/// loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent loadingProgress) {
/// if (loadingProgress == null)
/// return child;
/// return Center(
/// child: CircularProgressIndicator(
/// value: loadingProgress.expectedTotalBytes != null
/// ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes
/// : null,
/// ),
/// );
/// },
/// ),
/// );
/// }
/// ```
/// {@end-tool}
///
/// Run against a real-world image, the previous example renders the following
/// loading progress indicator while the image loads before rendering the
/// completed image.
///
/// {@animation 400 400 https://flutter.github.io/assets-for-api-docs/assets/widgets/loading_progress_image.mp4}
final ImageLoadingBuilder loadingBuilder;
/// If non-null, require the image to have this width. /// If non-null, require the image to have this width.
/// ///
/// If null, the image will pick a size that best preserves its intrinsic /// If null, the image will pick a size that best preserves its intrinsic
...@@ -588,6 +833,8 @@ class Image extends StatefulWidget { ...@@ -588,6 +833,8 @@ class Image extends StatefulWidget {
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ImageProvider>('image', image)); properties.add(DiagnosticsProperty<ImageProvider>('image', image));
properties.add(DiagnosticsProperty<Function>('frameBuilder', frameBuilder));
properties.add(DiagnosticsProperty<Function>('loadingBuilder', loadingBuilder));
properties.add(DoubleProperty('width', width, defaultValue: null)); properties.add(DoubleProperty('width', width, defaultValue: null));
properties.add(DoubleProperty('height', height, defaultValue: null)); properties.add(DoubleProperty('height', height, defaultValue: null));
properties.add(DiagnosticsProperty<Color>('color', color, defaultValue: null)); properties.add(DiagnosticsProperty<Color>('color', color, defaultValue: null));
...@@ -603,16 +850,32 @@ class Image extends StatefulWidget { ...@@ -603,16 +850,32 @@ class Image extends StatefulWidget {
} }
} }
class _ImageState extends State<Image> { class _ImageState extends State<Image> with WidgetsBindingObserver {
ImageStream _imageStream; ImageStream _imageStream;
ImageInfo _imageInfo; ImageInfo _imageInfo;
ImageChunkEvent _loadingProgress;
bool _isListeningToStream = false; bool _isListeningToStream = false;
bool _invertColors; bool _invertColors;
int _frameNumber;
bool _wasSynchronouslyLoaded;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
assert(_imageStream != null);
WidgetsBinding.instance.removeObserver(this);
_stopListeningToStream();
super.dispose();
}
@override @override
void didChangeDependencies() { void didChangeDependencies() {
_invertColors = MediaQuery.of(context, nullOk: true)?.invertColors _updateInvertColors();
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
_resolveImage(); _resolveImage();
if (TickerMode.of(context)) if (TickerMode.of(context))
...@@ -626,16 +889,34 @@ class _ImageState extends State<Image> { ...@@ -626,16 +889,34 @@ class _ImageState extends State<Image> {
@override @override
void didUpdateWidget(Image oldWidget) { void didUpdateWidget(Image oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (_isListeningToStream &&
(widget.loadingBuilder == null) != (oldWidget.loadingBuilder == null)) {
_imageStream.removeListener(_getListener(oldWidget.loadingBuilder));
_imageStream.addListener(_getListener());
}
if (widget.image != oldWidget.image) if (widget.image != oldWidget.image)
_resolveImage(); _resolveImage();
} }
@override
void didChangeAccessibilityFeatures() {
super.didChangeAccessibilityFeatures();
setState(() {
_updateInvertColors();
});
}
@override @override
void reassemble() { void reassemble() {
_resolveImage(); // in case the image cache was flushed _resolveImage(); // in case the image cache was flushed
super.reassemble(); super.reassemble();
} }
void _updateInvertColors() {
_invertColors = MediaQuery.of(context, nullOk: true)?.invertColors
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
}
void _resolveImage() { void _resolveImage() {
final ImageStream newStream = final ImageStream newStream =
widget.image.resolve(createLocalImageConfiguration( widget.image.resolve(createLocalImageConfiguration(
...@@ -646,56 +927,72 @@ class _ImageState extends State<Image> { ...@@ -646,56 +927,72 @@ class _ImageState extends State<Image> {
_updateSourceStream(newStream); _updateSourceStream(newStream);
} }
void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) { ImageStreamListener _getListener([ImageLoadingBuilder loadingBuilder]) {
loadingBuilder ??= widget.loadingBuilder;
return ImageStreamListener(
_handleImageFrame,
onChunk: loadingBuilder == null ? null : _handleImageChunk,
);
}
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
assert(_frameNumber == null || !synchronousCall);
setState(() { setState(() {
_imageInfo = imageInfo; _imageInfo = imageInfo;
_loadingProgress = null;
_frameNumber = _frameNumber == null ? 0 : _frameNumber + 1;
_wasSynchronouslyLoaded |= synchronousCall;
}); });
} }
// Update _imageStream to newStream, and moves the stream listener void _handleImageChunk(ImageChunkEvent event) {
assert(widget.loadingBuilder != null);
setState(() {
_loadingProgress = event;
});
}
// Updates _imageStream to newStream, and moves the stream listener
// registration from the old stream to the new stream (if a listener was // registration from the old stream to the new stream (if a listener was
// registered). // registered).
void _updateSourceStream(ImageStream newStream) { void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream?.key) if (_imageStream?.key == newStream?.key)
return; return;
final ImageStreamListener listener = ImageStreamListener(_handleImageChanged);
if (_isListeningToStream) if (_isListeningToStream)
_imageStream.removeListener(listener); _imageStream.removeListener(_getListener());
if (!widget.gaplessPlayback) if (!widget.gaplessPlayback)
setState(() { _imageInfo = null; }); setState(() { _imageInfo = null; });
setState(() {
_loadingProgress = null;
_frameNumber = null;
_wasSynchronouslyLoaded = false;
});
_imageStream = newStream; _imageStream = newStream;
if (_isListeningToStream) if (_isListeningToStream)
_imageStream.addListener(listener); _imageStream.addListener(_getListener());
} }
void _listenToStream() { void _listenToStream() {
if (_isListeningToStream) if (_isListeningToStream)
return; return;
_imageStream.addListener(ImageStreamListener(_handleImageChanged)); _imageStream.addListener(_getListener());
_isListeningToStream = true; _isListeningToStream = true;
} }
void _stopListeningToStream() { void _stopListeningToStream() {
if (!_isListeningToStream) if (!_isListeningToStream)
return; return;
_imageStream.removeListener(ImageStreamListener(_handleImageChanged)); _imageStream.removeListener(_getListener());
_isListeningToStream = false; _isListeningToStream = false;
} }
@override
void dispose() {
assert(_imageStream != null);
_stopListeningToStream();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final RawImage image = RawImage( Widget result = RawImage(
image: _imageInfo?.image, image: _imageInfo?.image,
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
...@@ -710,20 +1007,32 @@ class _ImageState extends State<Image> { ...@@ -710,20 +1007,32 @@ class _ImageState extends State<Image> {
invertColors: _invertColors, invertColors: _invertColors,
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
); );
if (widget.excludeFromSemantics)
return image; if (!widget.excludeFromSemantics) {
return Semantics( result = Semantics(
container: widget.semanticLabel != null, container: widget.semanticLabel != null,
image: true, image: true,
label: widget.semanticLabel ?? '', label: widget.semanticLabel ?? '',
child: image, child: result,
); );
} }
if (widget.frameBuilder != null)
result = widget.frameBuilder(context, result, _frameNumber, _wasSynchronouslyLoaded);
if (widget.loadingBuilder != null)
result = widget.loadingBuilder(context, result, _loadingProgress);
return result;
}
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder description) { void debugFillProperties(DiagnosticPropertiesBuilder description) {
super.debugFillProperties(description); super.debugFillProperties(description);
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream)); description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo)); description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
} }
} }
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui show Image, ImageByteFormat; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
...@@ -14,6 +14,14 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -14,6 +14,14 @@ import 'package:flutter_test/flutter_test.dart';
import '../painting/image_data.dart'; import '../painting/image_data.dart';
import 'semantics_tester.dart'; import 'semantics_tester.dart';
// This must be run with [WidgetTester.runAsync] since it performs real async
// work.
Future<ui.Image> createTestImage([List<int> bytes = kTransparentImage]) async {
final ui.Codec codec = await ui.instantiateImageCodec(Uint8List.fromList(bytes));
final ui.FrameInfo frameInfo = await codec.getNextFrame();
return frameInfo.image;
}
void main() { void main() {
testWidgets('Verify Image resets its RenderImage when changing providers', (WidgetTester tester) async { testWidgets('Verify Image resets its RenderImage when changing providers', (WidgetTester tester) async {
final GlobalKey key = GlobalKey(); final GlobalKey key = GlobalKey();
...@@ -308,12 +316,12 @@ void main() { ...@@ -308,12 +316,12 @@ void main() {
final TestImageProvider imageProvider = TestImageProvider(); final TestImageProvider imageProvider = TestImageProvider();
await tester.pumpWidget(Image(image: imageProvider, excludeFromSemantics: true)); await tester.pumpWidget(Image(image: imageProvider, excludeFromSemantics: true));
final State<Image> image = tester.state/*State<Image>*/(find.byType(Image)); final State<Image> image = tester.state/*State<Image>*/(find.byType(Image));
expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, unresolved, 2 listeners), pixels: null)')); expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, unresolved, 2 listeners), pixels: null, loadingProgress: null, frameNumber: null, wasSynchronouslyLoaded: false)'));
imageProvider.complete(); imageProvider.complete();
await tester.pump(); await tester.pump();
expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, [100×100] @ 1.0x, 1 listener), pixels: [100×100] @ 1.0x)')); expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, [100×100] @ 1.0x, 1 listener), pixels: [100×100] @ 1.0x, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)'));
await tester.pumpWidget(Container()); await tester.pumpWidget(Container());
expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(lifecycle state: defunct, not mounted, stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, [100×100] @ 1.0x, 0 listeners), pixels: [100×100] @ 1.0x)')); expect(image.toString(), equalsIgnoringHashCodes('_ImageState#00000(lifecycle state: defunct, not mounted, stream: ImageStream#00000(OneFrameImageStreamCompleter#00000, [100×100] @ 1.0x, 0 listeners), pixels: [100×100] @ 1.0x, loadingProgress: null, frameNumber: 0, wasSynchronouslyLoaded: false)'));
}); });
testWidgets('Stream completer errors can be listened to by attaching before resolving', (WidgetTester tester) async { testWidgets('Stream completer errors can be listened to by attaching before resolving', (WidgetTester tester) async {
...@@ -808,6 +816,292 @@ void main() { ...@@ -808,6 +816,292 @@ void main() {
))); )));
semantics.dispose(); semantics.dispose();
}); });
testWidgets('Image invokes frameBuilder with correct frameNumber argument', (WidgetTester tester) async {
final ui.Codec codec = await tester.runAsync(() {
return ui.instantiateImageCodec(Uint8List.fromList(kAnimatedGif));
});
Future<ui.Image> nextFrame() async {
final ui.FrameInfo frameInfo = await tester.runAsync(codec.getNextFrame);
return frameInfo.image;
}
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter();
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
int lastFrame;
await tester.pumpWidget(
Image(
image: imageProvider,
frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) {
lastFrame = frame;
return Center(child: child);
},
),
);
expect(lastFrame, isNull);
expect(find.byType(Center), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
streamCompleter.notifyListeners(imageInfo: ImageInfo(image: await nextFrame()));
await tester.pump();
expect(lastFrame, 0);
expect(find.byType(Center), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
streamCompleter.notifyListeners(imageInfo: ImageInfo(image: await nextFrame()));
await tester.pump();
expect(lastFrame, 1);
expect(find.byType(Center), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
});
testWidgets('Image invokes frameBuilder with correct wasSynchronouslyLoaded=false', (WidgetTester tester) async {
final ui.Image image = await tester.runAsync(createTestImage);
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter();
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
int lastFrame;
bool lastFrameWasSync;
await tester.pumpWidget(
Image(
image: imageProvider,
frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) {
lastFrame = frame;
lastFrameWasSync = wasSynchronouslyLoaded;
return child;
},
),
);
expect(lastFrame, isNull);
expect(lastFrameWasSync, isFalse);
expect(find.byType(RawImage), findsOneWidget);
streamCompleter.notifyListeners(imageInfo: ImageInfo(image: image));
await tester.pump();
expect(lastFrame, 0);
expect(lastFrameWasSync, isFalse);
});
testWidgets('Image invokes frameBuilder with correct wasSynchronouslyLoaded=true', (WidgetTester tester) async {
final ui.Image image = await tester.runAsync(createTestImage);
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter(ImageInfo(image: image));
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
int lastFrame;
bool lastFrameWasSync;
await tester.pumpWidget(
Image(
image: imageProvider,
frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) {
lastFrame = frame;
lastFrameWasSync = wasSynchronouslyLoaded;
return child;
},
),
);
expect(lastFrame, 0);
expect(lastFrameWasSync, isTrue);
expect(find.byType(RawImage), findsOneWidget);
streamCompleter.notifyListeners(imageInfo: ImageInfo(image: image));
await tester.pump();
expect(lastFrame, 1);
expect(lastFrameWasSync, isTrue);
});
testWidgets('Image state handles frameBuilder update', (WidgetTester tester) async {
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter();
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
await tester.pumpWidget(
Image(
image: imageProvider,
frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) {
return Center(child: child);
},
),
);
expect(find.byType(Center), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
final State<Image> state = tester.state(find.byType(Image));
await tester.pumpWidget(
Image(
image: imageProvider,
frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) {
return Padding(padding: const EdgeInsets.all(1), child: child);
},
),
);
expect(find.byType(Center), findsNothing);
expect(find.byType(Padding), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
expect(tester.state(find.byType(Image)), same(state));
});
testWidgets('Image invokes loadingBuilder on chunk event notification', (WidgetTester tester) async {
final ui.Image image = await tester.runAsync(createTestImage);
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter();
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
final List<ImageChunkEvent> chunkEvents = <ImageChunkEvent>[];
await tester.pumpWidget(
Image(
image: imageProvider,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent loadingProgress) {
chunkEvents.add(loadingProgress);
if (loadingProgress == null)
return child;
return Directionality(
textDirection: TextDirection.ltr,
child: Text('loading ${loadingProgress.cumulativeBytesLoaded} / ${loadingProgress.expectedTotalBytes}'),
);
},
),
);
expect(chunkEvents.length, 1);
expect(chunkEvents.first, isNull);
expect(tester.binding.hasScheduledFrame, isFalse);
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump();
expect(chunkEvents.length, 2);
expect(find.text('loading 10 / 100'), findsOneWidget);
expect(find.byType(RawImage), findsNothing);
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 30, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump();
expect(chunkEvents.length, 3);
expect(find.text('loading 30 / 100'), findsOneWidget);
expect(find.byType(RawImage), findsNothing);
streamCompleter.notifyListeners(imageInfo: ImageInfo(image: image));
await tester.pump();
expect(chunkEvents.length, 4);
expect(find.byType(Text), findsNothing);
expect(find.byType(RawImage), findsOneWidget);
});
testWidgets('Image doesn\'t rebuild on chunk events if loadingBuilder is null', (WidgetTester tester) async {
final ui.Image image = await tester.runAsync(createTestImage);
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter();
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
await tester.pumpWidget(
Image(
image: imageProvider,
excludeFromSemantics: true,
),
);
expect(tester.binding.hasScheduledFrame, isFalse);
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isFalse);
streamCompleter.notifyListeners(imageInfo: ImageInfo(image: image));
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump();
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isFalse);
expect(find.byType(RawImage), findsOneWidget);
});
testWidgets('Image chains the results of frameBuilder and loadingBuilder', (WidgetTester tester) async {
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter();
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
await tester.pumpWidget(
Image(
image: imageProvider,
excludeFromSemantics: true,
frameBuilder: (BuildContext context, Widget child, int frame, bool wasSynchronouslyLoaded) {
return Padding(padding: const EdgeInsets.all(1), child: child);
},
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent loadingProgress) {
return Center(child: child);
},
),
);
expect(find.byType(Center), findsOneWidget);
expect(find.byType(Padding), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
expect(tester.widget<Padding>(find.byType(Padding)).child, isInstanceOf<RawImage>());
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
await tester.pump();
expect(find.byType(Center), findsOneWidget);
expect(find.byType(Padding), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
expect(tester.widget<Center>(find.byType(Center)).child, isInstanceOf<Padding>());
expect(tester.widget<Padding>(find.byType(Padding)).child, isInstanceOf<RawImage>());
});
testWidgets('Image state handles loadingBuilder update from null to non-null', (WidgetTester tester) async {
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter();
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
await tester.pumpWidget(
Image(image: imageProvider),
);
expect(find.byType(RawImage), findsOneWidget);
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isFalse);
final State<Image> state = tester.state(find.byType(Image));
await tester.pumpWidget(
Image(
image: imageProvider,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent loadingProgress) {
return Center(child: child);
},
),
);
expect(find.byType(Center), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
expect(tester.state(find.byType(Image)), same(state));
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump();
expect(find.byType(Center), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
});
testWidgets('Image state handles loadingBuilder update from non-null to null', (WidgetTester tester) async {
final TestImageStreamCompleter streamCompleter = TestImageStreamCompleter();
final TestImageProvider imageProvider = TestImageProvider(streamCompleter: streamCompleter);
await tester.pumpWidget(
Image(
image: imageProvider,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent loadingProgress) {
return Center(child: child);
},
),
);
expect(find.byType(Center), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isTrue);
await tester.pump();
expect(find.byType(Center), findsOneWidget);
expect(find.byType(RawImage), findsOneWidget);
final State<Image> state = tester.state(find.byType(Image));
await tester.pumpWidget(
Image(image: imageProvider),
);
expect(find.byType(Center), findsNothing);
expect(find.byType(RawImage), findsOneWidget);
expect(tester.state(find.byType(Image)), same(state));
streamCompleter.notifyListeners(chunkEvent: const ImageChunkEvent(cumulativeBytesLoaded: 10, expectedTotalBytes: 100));
expect(tester.binding.hasScheduledFrame, isFalse);
});
} }
class TestImageProvider extends ImageProvider<TestImageProvider> { class TestImageProvider extends ImageProvider<TestImageProvider> {
...@@ -847,17 +1141,37 @@ class TestImageProvider extends ImageProvider<TestImageProvider> { ...@@ -847,17 +1141,37 @@ class TestImageProvider extends ImageProvider<TestImageProvider> {
} }
class TestImageStreamCompleter extends ImageStreamCompleter { class TestImageStreamCompleter extends ImageStreamCompleter {
TestImageStreamCompleter([this.synchronousImage]);
final ImageInfo synchronousImage;
final Set<ImageStreamListener> listeners = <ImageStreamListener>{}; final Set<ImageStreamListener> listeners = <ImageStreamListener>{};
@override @override
void addListener(ImageStreamListener listener) { void addListener(ImageStreamListener listener) {
listeners.add(listener); listeners.add(listener);
if (synchronousImage != null)
listener.onImage(synchronousImage, true);
} }
@override @override
void removeListener(ImageStreamListener listener) { void removeListener(ImageStreamListener listener) {
listeners.remove(listener); listeners.remove(listener);
} }
void notifyListeners({
ImageInfo imageInfo,
ImageChunkEvent chunkEvent,
}) {
final List<ImageStreamListener> localListeners = listeners.toList();
for (ImageStreamListener listener in localListeners) {
if (imageInfo != null) {
listener.onImage(imageInfo, false);
}
if (chunkEvent != null && listener.onChunk != null) {
listener.onChunk(chunkEvent);
}
}
}
} }
class TestImage implements ui.Image { class TestImage implements ui.Image {
......
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