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));