ImageDecoration.lerp (#130533) (#131349)

This primarily implements DecorationImage.lerp().

It also makes some minor tweaks, the main one of which is defering to dart:ui for `clampDouble` instead of duplicating it in package:foundation.

Fixes https://github.com/flutter/flutter/issues/12452

This was first landed in https://github.com/flutter/flutter/pull/130533 and reverted in https://github.com/flutter/flutter/pull/131347.
diff --git a/analysis_options.yaml b/analysis_options.yaml
index 747d8e2..a9d2963 100644
--- a/analysis_options.yaml
+++ b/analysis_options.yaml
@@ -50,7 +50,7 @@
     - avoid_field_initializers_in_const_classes
     # - avoid_final_parameters # incompatible with prefer_final_parameters
     - avoid_function_literals_in_foreach_calls
-    - avoid_implementing_value_types
+    # - avoid_implementing_value_types # see https://github.com/dart-lang/linter/issues/4558
     - avoid_init_to_null
     - avoid_js_rounded_ints
     # - avoid_multiple_declarations_per_line # seems to be a stylistic choice we don't subscribe to
diff --git a/packages/flutter/lib/foundation.dart b/packages/flutter/lib/foundation.dart
index 43ac813..378b206 100644
--- a/packages/flutter/lib/foundation.dart
+++ b/packages/flutter/lib/foundation.dart
@@ -34,7 +34,6 @@
 export 'src/foundation/isolates.dart';
 export 'src/foundation/key.dart';
 export 'src/foundation/licenses.dart';
-export 'src/foundation/math.dart';
 export 'src/foundation/memory_allocations.dart';
 export 'src/foundation/node.dart';
 export 'src/foundation/object.dart';
diff --git a/packages/flutter/lib/src/foundation/README.md b/packages/flutter/lib/src/foundation/README.md
index 5e4de07..d32effc 100644
--- a/packages/flutter/lib/src/foundation/README.md
+++ b/packages/flutter/lib/src/foundation/README.md
@@ -3,9 +3,9 @@
 can't depend on any `package:`, and they can't depend on anything
 outside this directory.
 
-Currently they do depend on dart:ui, but only for `VoidCallback` (and
-maybe one day `lerpDouble`), which are all intended to be moved out
-of `dart:ui` and into `dart:core`.
+Currently they do depend on dart:ui, but only for `VoidCallback` and
+`clampDouble` (and maybe one day `lerpDouble`), which are all intended
+to be moved out of `dart:ui` and into `dart:core`.
 
 There is currently also an unfortunate dependency on the platform
 dispatcher logic (SingletonFlutterWindow, Brightness,
@@ -14,5 +14,4 @@
 
 See also:
 
- * https://github.com/dart-lang/sdk/issues/27791 (`VoidCallback`)
- * https://github.com/dart-lang/sdk/issues/25217 (`hashValues`, `hashList`, and `lerpDouble`)
+ * https://github.com/dart-lang/sdk/issues/25217
diff --git a/packages/flutter/lib/src/foundation/binding.dart b/packages/flutter/lib/src/foundation/binding.dart
index de0bac7..83204bd 100644
--- a/packages/flutter/lib/src/foundation/binding.dart
+++ b/packages/flutter/lib/src/foundation/binding.dart
@@ -22,7 +22,7 @@
 import 'service_extensions.dart';
 import 'timeline.dart';
 
-export 'dart:ui' show PlatformDispatcher, SingletonFlutterWindow; // ignore: deprecated_member_use
+export 'dart:ui' show PlatformDispatcher, SingletonFlutterWindow, clampDouble; // ignore: deprecated_member_use
 
 export 'basic_types.dart' show AsyncCallback, AsyncValueGetter, AsyncValueSetter;
 
diff --git a/packages/flutter/lib/src/foundation/diagnostics.dart b/packages/flutter/lib/src/foundation/diagnostics.dart
index f4f9d5c..e5c1345 100644
--- a/packages/flutter/lib/src/foundation/diagnostics.dart
+++ b/packages/flutter/lib/src/foundation/diagnostics.dart
@@ -3,13 +3,13 @@
 // found in the LICENSE file.
 
 import 'dart:math' as math;
+import 'dart:ui' show clampDouble;
 
 import 'package:meta/meta.dart';
 
 import 'assertions.dart';
 import 'constants.dart';
 import 'debug.dart';
-import 'math.dart' show clampDouble;
 import 'object.dart';
 
 // Examples can assume:
diff --git a/packages/flutter/lib/src/foundation/math.dart b/packages/flutter/lib/src/foundation/math.dart
deleted file mode 100644
index 053192a..0000000
--- a/packages/flutter/lib/src/foundation/math.dart
+++ /dev/null
@@ -1,23 +0,0 @@
-// Copyright 2014 The Flutter Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-/// Same as [num.clamp] but optimized for non-null [double].
-///
-/// This is faster because it avoids polymorphism, boxing, and special cases for
-/// floating point numbers.
-//
-// See also: //dev/benchmarks/microbenchmarks/lib/foundation/clamp.dart
-double clampDouble(double x, double min, double max) {
-  assert(min <= max && !max.isNaN && !min.isNaN);
-  if (x < min) {
-    return min;
-  }
-  if (x > max) {
-    return max;
-  }
-  if (x.isNaN) {
-    return max;
-  }
-  return x;
-}
diff --git a/packages/flutter/lib/src/material/dialog.dart b/packages/flutter/lib/src/material/dialog.dart
index 57300b5..0b13366 100644
--- a/packages/flutter/lib/src/material/dialog.dart
+++ b/packages/flutter/lib/src/material/dialog.dart
@@ -2,10 +2,9 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-import 'dart:ui';
+import 'dart:ui' show clampDouble, lerpDouble;
 
 import 'package:flutter/cupertino.dart';
-import 'package:flutter/foundation.dart' show clampDouble;
 
 import 'color_scheme.dart';
 import 'colors.dart';
diff --git a/packages/flutter/lib/src/painting/box_decoration.dart b/packages/flutter/lib/src/painting/box_decoration.dart
index 444eb40..e076247 100644
--- a/packages/flutter/lib/src/painting/box_decoration.dart
+++ b/packages/flutter/lib/src/painting/box_decoration.dart
@@ -232,7 +232,7 @@
   BoxDecoration scale(double factor) {
     return BoxDecoration(
       color: Color.lerp(null, color, factor),
-      image: image, // TODO(ianh): fade the image from transparent
+      image: DecorationImage.lerp(null, image, factor),
       border: BoxBorder.lerp(null, border, factor),
       borderRadius: BorderRadiusGeometry.lerp(null, borderRadius, factor),
       boxShadow: BoxShadow.lerpList(null, boxShadow, factor),
@@ -307,7 +307,7 @@
     }
     return BoxDecoration(
       color: Color.lerp(a.color, b.color, t),
-      image: t < 0.5 ? a.image : b.image, // TODO(ianh): cross-fade the image
+      image: DecorationImage.lerp(a.image, b.image, t),
       border: BoxBorder.lerp(a.border, b.border, t),
       borderRadius: BorderRadiusGeometry.lerp(a.borderRadius, b.borderRadius, t),
       boxShadow: BoxShadow.lerpList(a.boxShadow, b.boxShadow, t),
diff --git a/packages/flutter/lib/src/painting/decoration_image.dart b/packages/flutter/lib/src/painting/decoration_image.dart
index 5c8ed9d..c91a9f0 100644
--- a/packages/flutter/lib/src/painting/decoration_image.dart
+++ b/packages/flutter/lib/src/painting/decoration_image.dart
@@ -177,7 +177,7 @@
   /// image needs to be repainted, e.g. because it is loading incrementally or
   /// because it is animated.
   DecorationImagePainter createPainter(VoidCallback onChanged) {
-    return DecorationImagePainter._(this, onChanged);
+    return _DecorationImagePainter._(this, onChanged);
   }
 
   @override
@@ -246,6 +246,28 @@
     ];
     return '${objectRuntimeType(this, 'DecorationImage')}(${properties.join(", ")})';
   }
+
+  /// Linearly interpolates between two [DecorationImage]s.
+  ///
+  /// The `t` argument represents position on the timeline, with 0.0 meaning
+  /// that the interpolation has not started, returning `a`, 1.0 meaning that
+  /// the interpolation has finished, returning `b`, and values in between
+  /// meaning that the interpolation is at the relevant point on the timeline
+  /// between `a` and `this`. The interpolation can be extrapolated beyond 0.0
+  /// and 1.0, so negative values and values greater than 1.0 are valid (and can
+  /// easily be generated by curves such as [Curves.elasticInOut]).
+  ///
+  /// Values for `t` are usually obtained from an [Animation<double>], such as
+  /// an [AnimationController].
+  static DecorationImage? lerp(DecorationImage? a, DecorationImage? b, double t) {
+    if (identical(a, b) || t == 0.0) {
+      return a;
+    }
+    if (t == 1.0) {
+      return b;
+    }
+    return _BlendedDecorationImage(a, b, t);
+  }
 }
 
 /// The painter for a [DecorationImage].
@@ -259,15 +281,7 @@
 ///
 /// This object should be disposed using the [dispose] method when it is no
 /// longer needed.
-class DecorationImagePainter {
-  DecorationImagePainter._(this._details, this._onChanged);
-
-  final DecorationImage _details;
-  final VoidCallback _onChanged;
-
-  ImageStream? _imageStream;
-  ImageInfo? _image;
-
+abstract interface class DecorationImagePainter {
   /// Draw the image onto the given canvas.
   ///
   /// The image is drawn at the position and size given by the `rect` argument.
@@ -282,8 +296,34 @@
   /// because it had not yet been loaded the first time this method was called,
   /// then the `onChanged` callback passed to [DecorationImage.createPainter]
   /// will be called.
-  void paint(Canvas canvas, Rect rect, Path? clipPath, ImageConfiguration configuration) {
+  ///
+  /// The `blend` argument specifies the opacity that should be applied to the
+  /// image due to this image being blended with another. The `blendMode`
+  /// argument can be specified to override the [DecorationImagePainter]'s
+  /// default [BlendMode] behavior. It is usually set to [BlendMode.srcOver] if
+  /// this is the first or only image being blended, and [BlendMode.plus] if it
+  /// is being blended with an image below.
+  void paint(Canvas canvas, Rect rect, Path? clipPath, ImageConfiguration configuration, { double blend = 1.0, BlendMode blendMode = BlendMode.srcOver });
 
+  /// Releases the resources used by this painter.
+  ///
+  /// This should be called whenever the painter is no longer needed.
+  ///
+  /// After this method has been called, the object is no longer usable.
+  void dispose();
+}
+
+class _DecorationImagePainter implements DecorationImagePainter {
+  _DecorationImagePainter._(this._details, this._onChanged);
+
+  final DecorationImage _details;
+  final VoidCallback _onChanged;
+
+  ImageStream? _imageStream;
+  ImageInfo? _image;
+
+  @override
+  void paint(Canvas canvas, Rect rect, Path? clipPath, ImageConfiguration configuration, { double blend = 1.0, BlendMode blendMode = BlendMode.srcOver }) {
     bool flipHorizontally = false;
     if (_details.matchTextDirection) {
       assert(() {
@@ -338,10 +378,11 @@
       centerSlice: _details.centerSlice,
       repeat: _details.repeat,
       flipHorizontally: flipHorizontally,
-      opacity: _details.opacity,
+      opacity: _details.opacity * blend,
       filterQuality: _details.filterQuality,
       invertColors: _details.invertColors,
       isAntiAlias: _details.isAntiAlias,
+      blendMode: blendMode,
     );
 
     if (clipPath != null) {
@@ -364,12 +405,7 @@
     }
   }
 
-  /// Releases the resources used by this painter.
-  ///
-  /// This should be called whenever the painter is no longer needed.
-  ///
-  /// After this method has been called, the object is no longer usable.
-  @mustCallSuper
+  @override
   void dispose() {
     _imageStream?.removeListener(ImageStreamListener(
       _handleImage,
@@ -444,7 +480,7 @@
 ///    corners of the destination rectangle defined by applying `fit`. The
 ///    remaining five regions are drawn by stretching them to fit such that they
 ///    exactly cover the destination rectangle while maintaining their relative
-///    positions.
+///    positions. See also [Canvas.drawImageNine].
 ///
 ///  * `repeat`: If the image does not fill `rect`, whether and how the image
 ///    should be repeated to fill `rect`. By default, the image is not repeated.
@@ -490,6 +526,7 @@
   bool invertColors = false,
   FilterQuality filterQuality = FilterQuality.low,
   bool isAntiAlias = false,
+  BlendMode blendMode = BlendMode.srcOver,
 }) {
   assert(
     image.debugGetOpenHandleStackTraces()?.isNotEmpty ?? true,
@@ -530,9 +567,10 @@
   if (colorFilter != null) {
     paint.colorFilter = colorFilter;
   }
-  paint.color = Color.fromRGBO(0, 0, 0, opacity);
+  paint.color = Color.fromRGBO(0, 0, 0, clampDouble(opacity, 0.0, 1.0));
   paint.filterQuality = filterQuality;
   paint.invertColors = invertColors;
+  paint.blendMode = blendMode;
   final double halfWidthDelta = (outputSize.width - destinationSize.width) / 2.0;
   final double halfHeightDelta = (outputSize.height - destinationSize.height) / 2.0;
   final double dx = halfWidthDelta + (flipHorizontally ? -alignment.x : alignment.x) * halfWidthDelta;
@@ -543,6 +581,12 @@
   // Set to true if we added a saveLayer to the canvas to invert/flip the image.
   bool invertedCanvas = false;
   // Output size and destination rect are fully calculated.
+
+  // Implement debug-mode and profile-mode features:
+  //  - cacheWidth/cacheHeight warning
+  //  - debugInvertOversizedImages
+  //  - debugOnPaintImage
+  //  - Flutter.ImageSizesForFrame events in timeline
   if (!kReleaseMode) {
     // We can use the devicePixelRatio of the views directly here (instead of
     // going through a MediaQuery) because if it changes, whatever is aware of
@@ -554,7 +598,6 @@
       0.0,
       (double previousValue, ui.FlutterView view) => math.max(previousValue, view.devicePixelRatio),
     );
-
     final ImageSizeInfo sizeInfo = ImageSizeInfo(
       // Some ImageProvider implementations may not have given this.
       source: debugImageLabel ?? '<Unknown Image(${image.width}×${image.height})>',
@@ -599,7 +642,7 @@
       return true;
     }());
     // Avoid emitting events that are the same as those emitted in the last frame.
-    if (!kReleaseMode && !_lastFrameImageSizeInfo.contains(sizeInfo)) {
+    if (!_lastFrameImageSizeInfo.contains(sizeInfo)) {
       final ImageSizeInfo? existingSizeInfo = _pendingImageSizeInfo[sizeInfo.source];
       if (existingSizeInfo == null || existingSizeInfo.displaySizeInBytes < sizeInfo.displaySizeInBytes) {
         _pendingImageSizeInfo[sizeInfo.source!] = sizeInfo;
@@ -691,3 +734,99 @@
 }
 
 Rect _scaleRect(Rect rect, double scale) => Rect.fromLTRB(rect.left * scale, rect.top * scale, rect.right * scale, rect.bottom * scale);
+
+// Implements DecorationImage.lerp when the image is different.
+//
+// This class just paints both decorations on top of each other, blended together.
+//
+// The Decoration properties are faked by just forwarded to the target image.
+class _BlendedDecorationImage implements DecorationImage {
+  const _BlendedDecorationImage(this.a, this.b, this.t) : assert(a != null || b != null);
+
+  final DecorationImage? a;
+  final DecorationImage? b;
+  final double t;
+
+  @override
+  ImageProvider get image => b?.image ?? a!.image;
+  @override
+  ImageErrorListener? get onError => b?.onError ?? a!.onError;
+  @override
+  ColorFilter? get colorFilter => b?.colorFilter ?? a!.colorFilter;
+  @override
+  BoxFit? get fit => b?.fit ?? a!.fit;
+  @override
+  AlignmentGeometry get alignment => b?.alignment ?? a!.alignment;
+  @override
+  Rect? get centerSlice => b?.centerSlice ?? a!.centerSlice;
+  @override
+  ImageRepeat get repeat => b?.repeat ?? a!.repeat;
+  @override
+  bool get matchTextDirection => b?.matchTextDirection ?? a!.matchTextDirection;
+  @override
+  double get scale => b?.scale ?? a!.scale;
+  @override
+  double get opacity => b?.opacity ?? a!.opacity;
+  @override
+  FilterQuality get filterQuality => b?.filterQuality ?? a!.filterQuality;
+  @override
+  bool get invertColors => b?.invertColors ?? a!.invertColors;
+  @override
+  bool get isAntiAlias => b?.isAntiAlias ?? a!.isAntiAlias;
+
+  @override
+  DecorationImagePainter createPainter(VoidCallback onChanged) {
+    return _BlendedDecorationImagePainter._(
+      a?.createPainter(onChanged),
+      b?.createPainter(onChanged),
+      t,
+    );
+  }
+
+  @override
+  bool operator ==(Object other) {
+    if (identical(this, other)) {
+      return true;
+    }
+    if (other.runtimeType != runtimeType) {
+      return false;
+    }
+    return other is _BlendedDecorationImage
+        && other.a == a
+        && other.b == b
+        && other.t == t;
+  }
+
+  @override
+  int get hashCode => Object.hash(a, b, t);
+
+  @override
+  String toString() {
+    return '${objectRuntimeType(this, '_BlendedDecorationImage')}($a, $b, $t)';
+  }
+}
+
+class _BlendedDecorationImagePainter implements DecorationImagePainter {
+  _BlendedDecorationImagePainter._(this.a, this.b, this.t);
+
+  final DecorationImagePainter? a;
+  final DecorationImagePainter? b;
+  final double t;
+
+  @override
+  void paint(Canvas canvas, Rect rect, Path? clipPath, ImageConfiguration configuration, { double blend = 1.0, BlendMode blendMode = BlendMode.srcOver }) {
+    a?.paint(canvas, rect, clipPath, configuration, blend: blend * (1.0 - t), blendMode: blendMode);
+    b?.paint(canvas, rect, clipPath, configuration, blend: blend * t, blendMode: a != null ? BlendMode.plus : blendMode);
+  }
+
+  @override
+  void dispose() {
+    a?.dispose();
+    b?.dispose();
+  }
+
+  @override
+  String toString() {
+    return '${objectRuntimeType(this, '_BlendedDecorationImagePainter')}($a, $b, $t)';
+  }
+}
diff --git a/packages/flutter/lib/src/painting/shape_decoration.dart b/packages/flutter/lib/src/painting/shape_decoration.dart
index 20785bc..bf7a0e8 100644
--- a/packages/flutter/lib/src/painting/shape_decoration.dart
+++ b/packages/flutter/lib/src/painting/shape_decoration.dart
@@ -237,7 +237,7 @@
     return ShapeDecoration(
       color: Color.lerp(a?.color, b?.color, t),
       gradient: Gradient.lerp(a?.gradient, b?.gradient, t),
-      image: t < 0.5 ? a?.image : b?.image, // TODO(ianh): cross-fade the image
+      image: DecorationImage.lerp(a?.image, b?.image, t),
       shadows: BoxShadow.lerpList(a?.shadows, b?.shadows, t),
       shape: ShapeBorder.lerp(a?.shape, b?.shape, t)!,
     );
diff --git a/packages/flutter/test/painting/decoration_image_lerp_test.dart b/packages/flutter/test/painting/decoration_image_lerp_test.dart
new file mode 100644
index 0000000..1e0f6f4
--- /dev/null
+++ b/packages/flutter/test/painting/decoration_image_lerp_test.dart
@@ -0,0 +1,440 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// This file is run as part of a reduced test set in CI on Mac and Windows
+// machines because it contains golden tests; see:
+// https://github.com/flutter/flutter/wiki/Writing-a-golden-file-test-for-package:flutter#reduced-test-set-tag
+@Tags(<String>['reduced-test-set'])
+library;
+
+import 'dart:async';
+import 'dart:ui' as ui;
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  testWidgets('ImageDecoration.lerp', (WidgetTester tester) async {
+    final MemoryImage green = MemoryImage(Uint8List.fromList(<int>[
+      0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,  0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
+      0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,  0x01, 0x03, 0x00, 0x00, 0x00, 0x25, 0xdb, 0x56,
+      0xca, 0x00, 0x00, 0x00, 0x03, 0x50, 0x4c, 0x54,  0x45, 0x00, 0xff, 0x00, 0x34, 0x5e, 0xc0, 0xa8,
+      0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54,  0x08, 0xd7, 0x63, 0x60, 0x00, 0x00, 0x00, 0x02,
+      0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00,  0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42,
+      0x60, 0x82,
+    ]));
+    final MemoryImage red = MemoryImage(Uint8List.fromList(<int>[
+      0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,  0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
+      0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,  0x01, 0x03, 0x00, 0x00, 0x00, 0x25, 0xdb, 0x56,
+      0xca, 0x00, 0x00, 0x00, 0x03, 0x50, 0x4c, 0x54,  0x45, 0xff, 0x00, 0x00, 0x19, 0xe2, 0x09, 0x37,
+      0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54,  0x08, 0xd7, 0x63, 0x60, 0x00, 0x00, 0x00, 0x02,
+      0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00,  0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42,
+      0x60, 0x82,
+    ]));
+
+    await tester.runAsync(() async {
+      await load(green);
+      await load(red);
+    });
+
+    await tester.pumpWidget(
+      ColoredBox(
+        color: Colors.white,
+        child: Align(
+          alignment: Alignment.topLeft,
+          child: RepaintBoundary(
+            child: Wrap(
+              textDirection: TextDirection.ltr,
+              children: <Widget>[
+                TestImage(
+                  DecorationImage(image: green, repeat: ImageRepeat.repeat)
+                ),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                  DecorationImage(image: red, repeat: ImageRepeat.repeat),
+                  0.0,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                  DecorationImage(image: red, repeat: ImageRepeat.repeat),
+                  0.1,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                  DecorationImage(image: red, repeat: ImageRepeat.repeat),
+                  0.2,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                  DecorationImage(image: red, repeat: ImageRepeat.repeat),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                  DecorationImage(image: red, repeat: ImageRepeat.repeat),
+                  0.8,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                  DecorationImage(image: red, repeat: ImageRepeat.repeat),
+                  0.9,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                  DecorationImage(image: red, repeat: ImageRepeat.repeat),
+                  1.0,
+                )),
+                TestImage(
+                  DecorationImage(image: red, repeat: ImageRepeat.repeat),
+                ),
+                for (double t = 0.0; t < 1.0; t += 0.125)
+                  TestImage(DecorationImage.lerp(
+                    DecorationImage.lerp(
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      t,
+                    ),
+                    DecorationImage.lerp(
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      t,
+                    ),
+                    t,
+                  )),
+                for (double t = 0.0; t < 1.0; t += 0.125)
+                  TestImage(DecorationImage.lerp(
+                    DecorationImage.lerp(
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      1.0 - t,
+                    ),
+                    DecorationImage.lerp(
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      t,
+                    ),
+                    t,
+                  )),
+                for (double t = 0.0; t < 1.0; t += 0.125)
+                  TestImage(DecorationImage.lerp(
+                    DecorationImage.lerp(
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      t,
+                    ),
+                    DecorationImage.lerp(
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      1.0 - t,
+                    ),
+                    t,
+                  )),
+                for (double t = 0.0; t < 1.0; t += 0.125)
+                  TestImage(DecorationImage.lerp(
+                    DecorationImage.lerp(
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      1.0 - t,
+                    ),
+                    DecorationImage.lerp(
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      DecorationImage(image: green, repeat: ImageRepeat.repeat),
+                      1.0 - t,
+                    ),
+                    t,
+                  )),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+
+    await expectLater(
+      find.byType(Wrap),
+      matchesGoldenFile('decoration_image.lerp.0.png'),
+    );
+
+    if (!kIsWeb) { // TODO(ianh): https://github.com/flutter/flutter/issues/130610
+      final ui.Image image = (await tester.binding.runAsync<ui.Image>(() => captureImage(find.byType(Wrap).evaluate().single)))!;
+      final Uint8List bytes = (await tester.binding.runAsync<ByteData?>(() => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba)))!.buffer.asUint8List();
+      expect(image.width, 792);
+      expect(image.height, 48);
+      expect(bytes, hasLength(image.width * image.height * 4));
+      Color getPixel(int x, int y) {
+        final int offset = (x + y * image.width) * 4;
+        return Color.fromARGB(0xFF, bytes[offset], bytes[offset + 1], bytes[offset + 2]);
+      }
+      Color getBlockPixel(int index) {
+        int x = 12 + index * 24;
+        final int y = 12 + (x ~/ image.width) * 24;
+        x %= image.width;
+        return getPixel(x, y);
+      }
+      const Color lime = Color(0xFF00FF00);
+      expect(getBlockPixel(0), lime); // pure green
+      expect(getBlockPixel(1), lime); // 100% green 0% red
+      expect(getBlockPixel(2), const Color(0xFF19E600));
+      expect(getBlockPixel(3), const Color(0xFF33CC00));
+      expect(getBlockPixel(4), const Color(0xFF808000)); // 50-50 mix green/red
+      expect(getBlockPixel(5), const Color(0xFFCD3200));
+      expect(getBlockPixel(6), const Color(0xFFE61900));
+      expect(getBlockPixel(7), const Color(0xFFFF0000)); // 0% green 100% red
+      expect(getBlockPixel(8), const Color(0xFFFF0000)); // pure red
+      for (int index = 9; index < 40; index += 1) {
+        expect(getBlockPixel(index), lime);
+      }
+    }
+  }, skip: kIsWeb); // TODO(ianh): https://github.com/flutter/flutter/issues/130612, https://github.com/flutter/flutter/issues/130609
+
+  testWidgets('ImageDecoration.lerp', (WidgetTester tester) async {
+    final MemoryImage cmyk = MemoryImage(Uint8List.fromList(<int>[
+      0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,  0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
+      0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04,  0x02, 0x03, 0x00, 0x00, 0x00, 0xd4, 0x9f, 0x76,
+      0xed, 0x00, 0x00, 0x00, 0x0c, 0x50, 0x4c, 0x54,  0x45, 0x00, 0xff, 0xff, 0xff, 0x00, 0xff, 0xff,
+      0xff, 0x00, 0x00, 0x00, 0x00, 0x3b, 0x4c, 0x59,  0x13, 0x00, 0x00, 0x00, 0x0e, 0x49, 0x44, 0x41,
+      0x54, 0x08, 0xd7, 0x63, 0x60, 0x05, 0xc2, 0xf5,  0x0c, 0xeb, 0x01, 0x03, 0x00, 0x01, 0x69, 0x19,
+      0xea, 0x34, 0x7b, 0x00, 0x00, 0x00, 0x00, 0x49,  0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
+    ]));
+    final MemoryImage wrgb = MemoryImage(Uint8List.fromList(<int>[
+      0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,  0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
+      0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04,  0x02, 0x03, 0x00, 0x00, 0x00, 0xd4, 0x9f, 0x76,
+      0xed, 0x00, 0x00, 0x00, 0x0c, 0x50, 0x4c, 0x54,  0x45, 0xff, 0xff, 0xff, 0x00, 0x00, 0xff, 0x00,
+      0xff, 0x00, 0xff, 0x00, 0x00, 0x1e, 0x46, 0xbb,  0x1c, 0x00, 0x00, 0x00, 0x0e, 0x49, 0x44, 0x41,
+      0x54, 0x08, 0xd7, 0x63, 0xe0, 0x07, 0xc2, 0xa5,  0x0c, 0x4b, 0x01, 0x03, 0x50, 0x01, 0x69, 0x4a,
+      0x78, 0x1d, 0x41, 0x00, 0x00, 0x00, 0x00, 0x49,  0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
+    ]));
+
+    await tester.runAsync(() async {
+      await load(cmyk);
+      await load(wrgb);
+    });
+
+    await tester.pumpWidget(
+      ColoredBox(
+        color: Colors.white,
+        child: Align(
+          alignment: Alignment.topLeft,
+          child: RepaintBoundary(
+            child: Wrap(
+              textDirection: TextDirection.ltr,
+              children: <Widget>[
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, fit: BoxFit.contain),
+                  DecorationImage(image: cmyk, fit: BoxFit.contain),
+                  0.0,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, fit: BoxFit.contain),
+                  DecorationImage(image: cmyk, fit: BoxFit.contain),
+                  0.1,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, fit: BoxFit.contain),
+                  DecorationImage(image: cmyk, fit: BoxFit.contain),
+                  0.2,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, fit: BoxFit.contain),
+                  DecorationImage(image: cmyk, fit: BoxFit.contain),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, fit: BoxFit.contain),
+                  DecorationImage(image: cmyk, fit: BoxFit.contain),
+                  0.8,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, fit: BoxFit.contain),
+                  DecorationImage(image: cmyk, fit: BoxFit.contain),
+                  0.9,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, fit: BoxFit.contain),
+                  DecorationImage(image: cmyk, fit: BoxFit.contain),
+                  1.0,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, fit: BoxFit.cover),
+                  DecorationImage(image: cmyk, repeat: ImageRepeat.repeat),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, repeat: ImageRepeat.repeat),
+                  DecorationImage(image: cmyk, repeat: ImageRepeat.repeatY),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, repeat: ImageRepeat.repeatX),
+                  DecorationImage(image: cmyk, repeat: ImageRepeat.repeat),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, repeat: ImageRepeat.repeat, opacity: 0.2),
+                  DecorationImage(image: cmyk, repeat: ImageRepeat.repeat, opacity: 0.2),
+                  0.25,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, repeat: ImageRepeat.repeat, opacity: 0.2),
+                  DecorationImage(image: cmyk, repeat: ImageRepeat.repeat, opacity: 0.2),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, repeat: ImageRepeat.repeat, opacity: 0.2),
+                  DecorationImage(image: cmyk, repeat: ImageRepeat.repeat, opacity: 0.2),
+                  0.75,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: wrgb, scale: 0.5, repeat: ImageRepeat.repeatX),
+                  DecorationImage(image: cmyk, scale: 0.25, repeat: ImageRepeat.repeatY),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  0.0,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  0.25,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  0.75,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  1.0,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(0.0, 0.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  0.0,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(0.0, 0.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  0.25,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(0.0, 0.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  0.5,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(0.0, 0.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  0.75,
+                )),
+                TestImage(DecorationImage.lerp(
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(0.0, 0.0, 1.0, 1.0)),
+                  DecorationImage(image: cmyk, centerSlice: const Rect.fromLTWH(2.0, 2.0, 1.0, 1.0)),
+                  1.0,
+                )),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+
+    await expectLater(
+      find.byType(Wrap),
+      matchesGoldenFile('decoration_image.lerp.1.png'),
+    );
+
+    if (!kIsWeb) { // TODO(ianh): https://github.com/flutter/flutter/issues/130610
+      final ui.Image image = (await tester.binding.runAsync<ui.Image>(() => captureImage(find.byType(Wrap).evaluate().single)))!;
+      final Uint8List bytes = (await tester.binding.runAsync<ByteData?>(() => image.toByteData(format: ui.ImageByteFormat.rawStraightRgba)))!.buffer.asUint8List();
+      expect(image.width, 24 * 24);
+      expect(image.height, 1 * 24);
+      expect(bytes, hasLength(image.width * image.height * 4));
+      Color getPixel(int x, int y) {
+        final int offset = (x + y * image.width) * 4;
+        return Color.fromARGB(0xFF, bytes[offset], bytes[offset + 1], bytes[offset + 2]);
+      }
+      Color getPixelFromBlock(int index, int dx, int dy) {
+        const int padding = 2;
+        int x = index * 24 + dx + padding;
+        final int y = (x ~/ image.width) * 24 + dy + padding;
+        x %= image.width;
+        return getPixel(x, y);
+      }
+      // wrgb image
+      expect(getPixelFromBlock(0, 5, 5), const Color(0xFFFFFFFF));
+      expect(getPixelFromBlock(0, 15, 5), const Color(0xFFFF0000));
+      expect(getPixelFromBlock(0, 5, 15), const Color(0xFF00FF00));
+      expect(getPixelFromBlock(0, 15, 15), const Color(0xFF0000FF));
+      // wrgb/cmyk 50/50 blended image
+      expect(getPixelFromBlock(3, 5, 5), const Color(0xFF80FFFF));
+      expect(getPixelFromBlock(3, 15, 5), const Color(0xFFFF0080));
+      expect(getPixelFromBlock(3, 5, 15), const Color(0xFF80FF00));
+      expect(getPixelFromBlock(3, 15, 15), const Color(0xFF000080));
+      // cmyk image
+      expect(getPixelFromBlock(6, 5, 5), const Color(0xFF00FFFF));
+      expect(getPixelFromBlock(6, 15, 5), const Color(0xFFFF00FF));
+      expect(getPixelFromBlock(6, 5, 15), const Color(0xFFFFFF00));
+      expect(getPixelFromBlock(6, 15, 15), const Color(0xFF000000));
+      // top left corner control
+      expect(getPixelFromBlock(14, 0, 0), const Color(0xFF00FFFF));
+      expect(getPixelFromBlock(14, 1, 1), const Color(0xFF00FFFF));
+      expect(getPixelFromBlock(14, 2, 0), const Color(0xFFFF00FF));
+      expect(getPixelFromBlock(14, 19, 0), const Color(0xFFFF00FF));
+      expect(getPixelFromBlock(14, 0, 2), const Color(0xFFFFFF00));
+      expect(getPixelFromBlock(14, 0, 19), const Color(0xFFFFFF00));
+      expect(getPixelFromBlock(14, 2, 2), const Color(0xFF000000));
+      expect(getPixelFromBlock(14, 19, 19), const Color(0xFF000000));
+      // bottom right corner control
+      expect(getPixelFromBlock(19, 0, 0), const Color(0xFF00FFFF));
+      expect(getPixelFromBlock(19, 17, 17), const Color(0xFF00FFFF));
+      expect(getPixelFromBlock(19, 19, 0), const Color(0xFFFF00FF));
+      expect(getPixelFromBlock(19, 19, 17), const Color(0xFFFF00FF));
+      expect(getPixelFromBlock(19, 0, 19), const Color(0xFFFFFF00));
+      expect(getPixelFromBlock(19, 17, 19), const Color(0xFFFFFF00));
+      expect(getPixelFromBlock(19, 18, 18), const Color(0xFF000000));
+      expect(getPixelFromBlock(19, 19, 19), const Color(0xFF000000));
+    }
+  }, skip: kIsWeb); // TODO(ianh): https://github.com/flutter/flutter/issues/130612, https://github.com/flutter/flutter/issues/130609
+}
+
+Future<void> load(MemoryImage image) {
+  final ImageStream stream = image.resolve(ImageConfiguration.empty);
+  final Completer<ImageInfo> completer = Completer<ImageInfo>();
+  void listener(ImageInfo image, bool syncCall) {
+    completer.complete(image);
+  }
+  stream.addListener(ImageStreamListener(listener));
+  return completer.future;
+}
+
+class TestImage extends StatelessWidget {
+  TestImage(this.image); // ignore: use_key_in_widget_constructors, prefer_const_constructors_in_immutables
+
+  final DecorationImage? image;
+
+  @override
+  Widget build(BuildContext context) {
+    return Padding(
+      padding: const EdgeInsets.all(2.0),
+      child: SizedBox(
+        width: 20,
+        height: 20,
+        child: DecoratedBox(
+          decoration: BoxDecoration(
+            image: image,
+          ),
+        ),
+      ),
+    );
+  }
+}