feat: Add placeholder to DecorationImage (#191528)
This PR adds an optional `placeholder` to `DecorationImage`, so a box
decoration can show a cheap, locally available image while its main
image is still loading instead of rendering nothing.
`_DecorationImagePainter` resolves `placeholder` only while `image` has
not resolved yet, and paints whichever of the two is available. As soon
as `image` arrives, the placeholder's stream listener is removed and its
`ImageInfo` is disposed, so the placeholder does not outlive its
usefulness. Late placeholder frames that arrive after the image has
loaded are disposed immediately.
Fixes: https://github.com/flutter/flutter/issues/187906
## Pre-launch Checklist
- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [AI contribution guidelines] and understand my
responsibilities, or I am not using AI tools.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.
Co-authored-by: Victor Sanni <victorsanniay@gmail.com>
diff --git a/packages/flutter/lib/src/painting/decoration_image.dart b/packages/flutter/lib/src/painting/decoration_image.dart
index fd44082..680bdeb 100644
--- a/packages/flutter/lib/src/painting/decoration_image.dart
+++ b/packages/flutter/lib/src/painting/decoration_image.dart
@@ -49,6 +49,7 @@
/// Creates an image to show in a [BoxDecoration].
const DecorationImage({
required this.image,
+ this.placeholder,
this.onError,
this.colorFilter,
this.fit,
@@ -69,7 +70,18 @@
/// application) or a [NetworkImage] (for an image obtained from the network).
final ImageProvider image;
- /// An optional error callback for errors emitted when loading [image].
+ /// An optional image to paint while [image] is loading.
+ ///
+ /// This is typically a cheap, locally available image, such as an
+ /// [AssetImage], used to avoid showing an empty box while [image] is being
+ /// fetched.
+ ///
+ /// Once [image] has loaded, the placeholder is no longer painted and its
+ /// resources are released.
+ final ImageProvider? placeholder;
+
+ /// An optional error callback for errors emitted when loading [image] or
+ /// [placeholder].
final ImageErrorListener? onError;
/// A color filter to apply to the image before painting it.
@@ -193,6 +205,7 @@
}
return other is DecorationImage &&
other.image == image &&
+ other.placeholder == placeholder &&
other.colorFilter == colorFilter &&
other.fit == fit &&
other.alignment == alignment &&
@@ -209,6 +222,7 @@
@override
int get hashCode => Object.hash(
image,
+ placeholder,
colorFilter,
fit,
alignment,
@@ -226,6 +240,7 @@
String toString() {
final properties = <String>[
'$image',
+ if (placeholder != null) 'placeholder: $placeholder',
if (colorFilter != null) '$colorFilter',
if (fit != null &&
!(fit == BoxFit.fill && centerSlice != null) &&
@@ -328,6 +343,9 @@
ImageStream? _imageStream;
ImageInfo? _image;
+ ImageStream? _placeholderStream;
+ ImageInfo? _placeholderImage;
+
@override
void paint(
Canvas canvas,
@@ -370,6 +388,16 @@
}
}
+ if (_image == null && _details.placeholder != null) {
+ final ImageStream newPlaceholderStream = _details.placeholder!.resolve(configuration);
+ if (newPlaceholderStream.key != _placeholderStream?.key) {
+ final listener = ImageStreamListener(_handlePlaceholderImage, onError: _details.onError);
+ _placeholderStream?.removeListener(listener);
+ _placeholderStream = newPlaceholderStream;
+ _placeholderStream!.addListener(listener);
+ }
+ }
+
final ImageStream newImageStream = _details.image.resolve(configuration);
if (newImageStream.key != _imageStream?.key) {
final listener = ImageStreamListener(_handleImage, onError: _details.onError);
@@ -377,7 +405,8 @@
_imageStream = newImageStream;
_imageStream!.addListener(listener);
}
- if (_image == null) {
+ final ImageInfo? imageInfo = _image ?? _placeholderImage;
+ if (imageInfo == null) {
return;
}
@@ -389,9 +418,9 @@
paintImage(
canvas: canvas,
rect: rect,
- image: _image!.image,
- debugImageLabel: _image!.debugLabel,
- scale: _details.scale * _image!.scale,
+ image: imageInfo.image,
+ debugImageLabel: imageInfo.debugLabel,
+ scale: _details.scale * imageInfo.scale,
colorFilter: _details.colorFilter,
fit: _details.fit,
alignment: _details.alignment.resolve(configuration.textDirection),
@@ -420,17 +449,48 @@
}
_image?.dispose();
_image = value;
+ _disposePlaceholder();
if (!synchronousCall) {
_onChanged();
}
}
+ void _handlePlaceholderImage(ImageInfo value, bool synchronousCall) {
+ if (_image != null) {
+ // The image finished loading first; the placeholder is no longer needed.
+ value.dispose();
+ return;
+ }
+ if (_placeholderImage == value) {
+ return;
+ }
+ if (_placeholderImage != null && _placeholderImage!.isCloneOf(value)) {
+ value.dispose();
+ return;
+ }
+ _placeholderImage?.dispose();
+ _placeholderImage = value;
+ if (!synchronousCall) {
+ _onChanged();
+ }
+ }
+
+ void _disposePlaceholder() {
+ _placeholderStream?.removeListener(
+ ImageStreamListener(_handlePlaceholderImage, onError: _details.onError),
+ );
+ _placeholderStream = null;
+ _placeholderImage?.dispose();
+ _placeholderImage = null;
+ }
+
@override
void dispose() {
assert(debugMaybeDispatchDisposed(this));
_imageStream?.removeListener(ImageStreamListener(_handleImage, onError: _details.onError));
_image?.dispose();
_image = null;
+ _disposePlaceholder();
}
@override
@@ -805,6 +865,8 @@
@override
ImageProvider get image => b?.image ?? a!.image;
@override
+ ImageProvider? get placeholder => b?.placeholder ?? a!.placeholder;
+ @override
ImageErrorListener? get onError => b?.onError ?? a!.onError;
@override
ColorFilter? get colorFilter => b?.colorFilter ?? a!.colorFilter;
diff --git a/packages/flutter/test/painting/decoration_test.dart b/packages/flutter/test/painting/decoration_test.dart
index c22a4ea..bf38809 100644
--- a/packages/flutter/test/painting/decoration_test.dart
+++ b/packages/flutter/test/painting/decoration_test.dart
@@ -24,13 +24,14 @@
}
class SynchronousTestImageProvider extends ImageProvider<int> {
- const SynchronousTestImageProvider(this.image);
+ const SynchronousTestImageProvider(this.image, {this.cacheKey = 1});
final ui.Image image;
+ final int cacheKey;
@override
Future<int> obtainKey(ImageConfiguration configuration) {
- return SynchronousFuture<int>(1);
+ return SynchronousFuture<int>(cacheKey);
}
@override
@@ -123,6 +124,17 @@
}
}
+ui.Image _lastDrawnImage(TestCanvas canvas) {
+ final Invocation call = canvas.invocations.lastWhere(
+ (Invocation call) => call.memberName == #drawImageRect,
+ );
+ return call.positionalArguments[0] as ui.Image;
+}
+
+Matcher isSameSizeAs(ui.Image image) => isA<ui.Image>()
+ .having((ui.Image drawn) => drawn.width, 'width', image.width)
+ .having((ui.Image drawn) => drawn.height, 'height', image.height);
+
void main() {
TestRenderingFlutterBinding.ensureInitialized();
@@ -771,6 +783,105 @@
expect(call.positionalArguments[2], const Rect.fromLTRB(0.0, 0.0, 25.0, 25.0));
});
+ test('DecorationImage paints the placeholder until the image is available', () async {
+ final ui.Image placeholder = await createTestImage(width: 10, height: 10);
+ final ui.Image image = await createTestImage(width: 100, height: 100);
+ final delayedProvider = DelayedImageProvider(image);
+ final backgroundImage = DecorationImage(
+ image: delayedProvider,
+ placeholder: SynchronousTestImageProvider(placeholder, cacheKey: 3),
+ );
+
+ var onChangedCallCount = 0;
+ final DecorationImagePainter painter = backgroundImage.createPainter(() {
+ onChangedCallCount += 1;
+ });
+ addTearDown(painter.dispose);
+
+ var canvas = TestCanvas();
+ painter.paint(
+ canvas,
+ const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0),
+ null,
+ ImageConfiguration.empty,
+ );
+ expect(_lastDrawnImage(canvas), isSameSizeAs(placeholder));
+
+ await delayedProvider.complete();
+ await null;
+ expect(onChangedCallCount, 1);
+
+ canvas = TestCanvas();
+ painter.paint(
+ canvas,
+ const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0),
+ null,
+ ImageConfiguration.empty,
+ );
+ expect(_lastDrawnImage(canvas), isSameSizeAs(image));
+ });
+
+ test('DecorationImage does not paint the placeholder when the image is available', () async {
+ final ui.Image placeholder = await createTestImage(width: 10, height: 10);
+ final ui.Image image = await createTestImage(width: 100, height: 100);
+ final backgroundImage = DecorationImage(
+ image: SynchronousTestImageProvider(image, cacheKey: 4),
+ placeholder: SynchronousTestImageProvider(placeholder, cacheKey: 5),
+ );
+
+ final DecorationImagePainter painter = backgroundImage.createPainter(() {
+ assert(false);
+ });
+ addTearDown(painter.dispose);
+
+ final canvas = TestCanvas();
+ painter.paint(
+ canvas,
+ const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0),
+ null,
+ ImageConfiguration.empty,
+ );
+ expect(_lastDrawnImage(canvas), isSameSizeAs(image));
+ });
+
+ test('DecorationImagePainter releases the placeholder once the image loads', () async {
+ final ui.Image placeholder = await createTestImage(width: 10, height: 10);
+ final ui.Image image = await createTestImage(width: 100, height: 100);
+ final delayedProvider = DelayedImageProvider(image);
+ final DecorationImagePainter painter = DecorationImage(
+ image: delayedProvider,
+ placeholder: SynchronousTestImageProvider(placeholder, cacheKey: 7),
+ ).createPainter(() {});
+ addTearDown(painter.dispose);
+
+ painter.paint(
+ TestCanvas(),
+ const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0),
+ null,
+ ImageConfiguration.empty,
+ );
+ final int handleCountWithPlaceholder = placeholder.debugGetOpenHandleStackTraces()!.length;
+
+ await delayedProvider.complete();
+ await null;
+ expect(placeholder.debugGetOpenHandleStackTraces()!.length, handleCountWithPlaceholder - 1);
+ }, skip: kIsWeb); // https://github.com/flutter/flutter/issues/87442
+
+ test('DecorationImage placeholder is included in equality and toString', () async {
+ final ui.Image image = await createTestImage(width: 100, height: 100);
+ final ImageProvider provider = SynchronousTestImageProvider(image);
+ final ImageProvider placeholder = SynchronousTestImageProvider(image, cacheKey: 6);
+
+ expect(
+ DecorationImage(image: provider, placeholder: placeholder),
+ isNot(DecorationImage(image: provider)),
+ );
+ expect(
+ DecorationImage(image: provider, placeholder: placeholder).toString(),
+ contains('placeholder: SynchronousTestImageProvider()'),
+ );
+ });
+
test('DecorationImagePainter disposes of image when disposed', () async {
final ImageProvider provider = MemoryImage(Uint8List.fromList(kTransparentImage));