diff --git a/packages/flutter/lib/src/painting/decoration_image.dart b/packages/flutter/lib/src/painting/decoration_image.dart index 8c7d828f6a..a7b1600071 100644 --- a/packages/flutter/lib/src/painting/decoration_image.dart +++ b/packages/flutter/lib/src/painting/decoration_image.dart @@ -360,6 +360,8 @@ void debugFlushLastFrameImageSizeInfo() { /// /// * `scale`: The number of image pixels for each logical pixel. /// +/// * `opacity`: The opacity to paint the image onto the canvas with. +/// /// * `colorFilter`: If non-null, the color filter to apply when painting the /// image. /// @@ -420,6 +422,7 @@ void paintImage({ required ui.Image image, String? debugImageLabel, double scale = 1.0, + double opacity = 1.0, ColorFilter? colorFilter, BoxFit? fit, Alignment alignment = Alignment.center, @@ -473,6 +476,7 @@ void paintImage({ final Paint paint = Paint()..isAntiAlias = isAntiAlias; if (colorFilter != null) paint.colorFilter = colorFilter; + paint.color = Color.fromRGBO(0, 0, 0, opacity); paint.filterQuality = filterQuality; paint.invertColors = invertColors; final double halfWidthDelta = (outputSize.width - destinationSize.width) / 2.0; diff --git a/packages/flutter/lib/src/rendering/image.dart b/packages/flutter/lib/src/rendering/image.dart index dbaada359a..98c2d90ec4 100644 --- a/packages/flutter/lib/src/rendering/image.dart +++ b/packages/flutter/lib/src/rendering/image.dart @@ -4,6 +4,8 @@ import 'dart:ui' as ui show Image; +import 'package:flutter/animation.dart'; + import 'box.dart'; import 'object.dart'; @@ -31,6 +33,7 @@ class RenderImage extends RenderBox { double? height, double scale = 1.0, Color? color, + Animation? opacity, BlendMode? colorBlendMode, BoxFit? fit, AlignmentGeometry alignment = Alignment.center, @@ -52,6 +55,7 @@ class RenderImage extends RenderBox { _height = height, _scale = scale, _color = color, + _opacity = opacity, _colorBlendMode = colorBlendMode, _fit = fit, _alignment = alignment, @@ -163,6 +167,21 @@ class RenderImage extends RenderBox { markNeedsPaint(); } + /// If non-null, the value from the [Animation] is multiplied with the opacity + /// of each image pixel before painting onto the canvas. + Animation? get opacity => _opacity; + Animation? _opacity; + set opacity(Animation? value) { + if (value == _opacity) + return; + + if (attached) + _opacity?.removeListener(markNeedsPaint); + _opacity = value; + if (attached) + value?.addListener(markNeedsPaint); + } + /// Used to set the filterQuality of the image /// Use the [FilterQuality.low] quality setting to scale the image, which corresponds to /// bilinear interpolation, rather than the default [FilterQuality.none] which corresponds @@ -381,6 +400,18 @@ class RenderImage extends RenderBox { size = _sizeForConstraints(constraints); } + @override + void attach(covariant PipelineOwner owner) { + super.attach(owner); + _opacity?.addListener(markNeedsPaint); + } + + @override + void detach() { + _opacity?.removeListener(markNeedsPaint); + super.detach(); + } + @override void paint(PaintingContext context, Offset offset) { if (_image == null) @@ -394,6 +425,7 @@ class RenderImage extends RenderBox { image: _image!, debugImageLabel: debugImageLabel, scale: _scale, + opacity: _opacity?.value ?? 1.0, colorFilter: _colorFilter, fit: _fit, alignment: _resolvedAlignment!, @@ -414,6 +446,7 @@ class RenderImage extends RenderBox { properties.add(DoubleProperty('height', height, defaultValue: null)); properties.add(DoubleProperty('scale', scale, defaultValue: 1.0)); properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(DiagnosticsProperty?>('opacity', opacity, defaultValue: null)); properties.add(EnumProperty('colorBlendMode', colorBlendMode, defaultValue: null)); properties.add(EnumProperty('fit', fit, defaultValue: null)); properties.add(DiagnosticsProperty('alignment', alignment, defaultValue: null)); diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 02fc803213..5e12c170ce 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui show Image, ImageFilter, TextHeightBehavior; +import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; @@ -5915,6 +5916,7 @@ class RawImage extends LeafRenderObjectWidget { this.height, this.scale = 1.0, this.color, + this.opacity, this.colorBlendMode, this.fit, this.alignment = Alignment.center, @@ -5961,6 +5963,13 @@ class RawImage extends LeafRenderObjectWidget { /// If non-null, this color is blended with each image pixel using [colorBlendMode]. final Color? color; + /// If non-null, the value from the [Animation] is multiplied with the opacity + /// of each image pixel before painting onto the canvas. + /// + /// This is more efficient than using [FadeTransition] to change the opacity + /// of an image. + final Animation? opacity; + /// Used to set the filterQuality of the image /// Use the "low" quality setting to scale the image, which corresponds to /// bilinear interpolation, rather than the default "none" which corresponds @@ -6070,6 +6079,7 @@ class RawImage extends LeafRenderObjectWidget { height: height, scale: scale, color: color, + opacity: opacity, colorBlendMode: colorBlendMode, fit: fit, alignment: alignment, @@ -6122,6 +6132,7 @@ class RawImage extends LeafRenderObjectWidget { properties.add(DoubleProperty('height', height, defaultValue: null)); properties.add(DoubleProperty('scale', scale, defaultValue: 1.0)); properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(DiagnosticsProperty?>('opacity', opacity, defaultValue: null)); properties.add(EnumProperty('colorBlendMode', colorBlendMode, defaultValue: null)); properties.add(EnumProperty('fit', fit, defaultValue: null)); properties.add(DiagnosticsProperty('alignment', alignment, defaultValue: null)); diff --git a/packages/flutter/lib/src/widgets/fade_in_image.dart b/packages/flutter/lib/src/widgets/fade_in_image.dart index 35e3083f55..92a46e578f 100644 --- a/packages/flutter/lib/src/widgets/fade_in_image.dart +++ b/packages/flutter/lib/src/widgets/fade_in_image.dart @@ -11,7 +11,6 @@ import 'basic.dart'; import 'framework.dart'; import 'image.dart'; import 'implicit_animations.dart'; -import 'transitions.dart'; // Examples can assume: // late Uint8List bytes; @@ -62,7 +61,7 @@ import 'transitions.dart'; /// ) /// ``` /// {@end-tool} -class FadeInImage extends StatelessWidget { +class FadeInImage extends StatefulWidget { /// Creates a widget that displays a [placeholder] while an [image] is loading, /// then fades-out the placeholder and fades-in the image. /// @@ -356,22 +355,42 @@ class FadeInImage extends StatelessWidget { /// once the image has loaded. final String? imageSemanticLabel; + @override + State createState() => _FadeInImageState(); +} + +class _FadeInImageState extends State { + static const Animation _kOpaqueAnimation = AlwaysStoppedAnimation(1.0); + + // These ProxyAnimations are changed to the fade in animation by + // [_AnimatedFadeOutFadeInState]. Otherwise these animations are reset to + // their defaults by [_resetAnimations]. + final ProxyAnimation _imageAnimation = ProxyAnimation(_kOpaqueAnimation); + final ProxyAnimation _placeholderAnimation = ProxyAnimation(_kOpaqueAnimation); + + void _resetAnimations() { + _imageAnimation.parent = _kOpaqueAnimation; + _placeholderAnimation.parent = _kOpaqueAnimation; + } + Image _image({ required ImageProvider image, ImageErrorWidgetBuilder? errorBuilder, ImageFrameBuilder? frameBuilder, + required Animation opacity, }) { assert(image != null); return Image( image: image, errorBuilder: errorBuilder, frameBuilder: frameBuilder, - width: width, - height: height, - fit: fit, - alignment: alignment, - repeat: repeat, - matchTextDirection: matchTextDirection, + opacity: opacity, + width: widget.width, + height: widget.height, + fit: widget.fit, + alignment: widget.alignment, + repeat: widget.repeat, + matchTextDirection: widget.matchTextDirection, gaplessPlayback: true, excludeFromSemantics: true, ); @@ -380,28 +399,37 @@ class FadeInImage extends StatelessWidget { @override Widget build(BuildContext context) { Widget result = _image( - image: image, - errorBuilder: imageErrorBuilder, + image: widget.image, + errorBuilder: widget.imageErrorBuilder, + opacity: _imageAnimation, frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded) + if (wasSynchronouslyLoaded) { + _resetAnimations(); return child; + } return _AnimatedFadeOutFadeIn( target: child, - placeholder: _image(image: placeholder, errorBuilder: placeholderErrorBuilder), + targetProxyAnimation: _imageAnimation, + placeholder: _image( + image: widget.placeholder, + errorBuilder: widget.placeholderErrorBuilder, + opacity: _placeholderAnimation, + ), + placeholderProxyAnimation: _placeholderAnimation, isTargetLoaded: frame != null, - fadeInDuration: fadeInDuration, - fadeOutDuration: fadeOutDuration, - fadeInCurve: fadeInCurve, - fadeOutCurve: fadeOutCurve, + fadeInDuration: widget.fadeInDuration, + fadeOutDuration: widget.fadeOutDuration, + fadeInCurve: widget.fadeInCurve, + fadeOutCurve: widget.fadeOutCurve, ); }, ); - if (!excludeFromSemantics) { + if (!widget.excludeFromSemantics) { result = Semantics( - container: imageSemanticLabel != null, + container: widget.imageSemanticLabel != null, image: true, - label: imageSemanticLabel ?? '', + label: widget.imageSemanticLabel ?? '', child: result, ); } @@ -414,7 +442,9 @@ class _AnimatedFadeOutFadeIn extends ImplicitlyAnimatedWidget { const _AnimatedFadeOutFadeIn({ Key? key, required this.target, + required this.targetProxyAnimation, required this.placeholder, + required this.placeholderProxyAnimation, required this.isTargetLoaded, required this.fadeOutDuration, required this.fadeOutCurve, @@ -430,7 +460,9 @@ class _AnimatedFadeOutFadeIn extends ImplicitlyAnimatedWidget { super(key: key, duration: fadeInDuration + fadeOutDuration); final Widget target; + final ProxyAnimation targetProxyAnimation; final Widget placeholder; + final ProxyAnimation placeholderProxyAnimation; final bool isTargetLoaded; final Duration fadeInDuration; final Duration fadeOutDuration; @@ -494,6 +526,9 @@ class _AnimatedFadeOutFadeInState extends ImplicitlyAnimatedWidgetState<_Animate // for the full animation when the new target image becomes ready. controller.value = controller.upperBound; } + + widget.targetProxyAnimation.parent = _targetOpacityAnimation; + widget.placeholderProxyAnimation.parent = _placeholderOpacityAnimation; } bool _isValid(Tween tween) { @@ -502,13 +537,8 @@ class _AnimatedFadeOutFadeInState extends ImplicitlyAnimatedWidgetState<_Animate @override Widget build(BuildContext context) { - final Widget target = FadeTransition( - opacity: _targetOpacityAnimation!, - child: widget.target, - ); - if (_placeholderOpacityAnimation!.isCompleted) { - return target; + return widget.target; } return Stack( @@ -518,11 +548,8 @@ class _AnimatedFadeOutFadeInState extends ImplicitlyAnimatedWidgetState<_Animate // but it allows the Stack to avoid a call to Directionality.of() textDirection: TextDirection.ltr, children: [ - target, - FadeTransition( - opacity: _placeholderOpacityAnimation!, - child: widget.placeholder, - ), + widget.target, + widget.placeholder, ], ); } diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index 7bc05d250f..5b1354d585 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -330,6 +330,7 @@ class Image extends StatefulWidget { this.width, this.height, this.color, + this.opacity, this.colorBlendMode, this.fit, this.alignment = Alignment.center, @@ -389,6 +390,7 @@ class Image extends StatefulWidget { this.width, this.height, this.color, + this.opacity, this.colorBlendMode, this.fit, this.alignment = Alignment.center, @@ -451,6 +453,7 @@ class Image extends StatefulWidget { this.width, this.height, this.color, + this.opacity, this.colorBlendMode, this.fit, this.alignment = Alignment.center, @@ -612,6 +615,7 @@ class Image extends StatefulWidget { this.width, this.height, this.color, + this.opacity, this.colorBlendMode, this.fit, this.alignment = Alignment.center, @@ -681,6 +685,7 @@ class Image extends StatefulWidget { this.width, this.height, this.color, + this.opacity, this.colorBlendMode, this.fit, this.alignment = Alignment.center, @@ -922,6 +927,20 @@ class Image extends StatefulWidget { /// If non-null, this color is blended with each image pixel using [colorBlendMode]. final Color? color; + /// If non-null, the value from the [Animation] is multiplied with the opacity + /// of each image pixel before painting onto the canvas. + /// + /// This is more efficient than using [FadeTransition] to change the opacity + /// of an image, since this avoids creating a new composited layer. Composited + /// layers may double memory usage as the image is painted onto an offscreen + /// render target. + /// + /// See also: + /// + /// * [AlwaysStoppedAnimation], which allows you to create an [Animation] + /// from a single opacity value. + final Animation? opacity; + /// The rendering quality of the image. /// /// If the image is of a high quality and its pixels are perfectly aligned @@ -1071,6 +1090,7 @@ class Image extends StatefulWidget { properties.add(DoubleProperty('width', width, defaultValue: null)); properties.add(DoubleProperty('height', height, defaultValue: null)); properties.add(ColorProperty('color', color, defaultValue: null)); + properties.add(DiagnosticsProperty?>('opacity', opacity, defaultValue: null)); properties.add(EnumProperty('colorBlendMode', colorBlendMode, defaultValue: null)); properties.add(EnumProperty('fit', fit, defaultValue: null)); properties.add(DiagnosticsProperty('alignment', alignment, defaultValue: null)); @@ -1326,6 +1346,7 @@ class _ImageState extends State with WidgetsBindingObserver { height: widget.height, scale: _imageInfo?.scale ?? 1.0, color: widget.color, + opacity: widget.opacity, colorBlendMode: widget.colorBlendMode, fit: widget.fit, alignment: widget.alignment, diff --git a/packages/flutter/test/widgets/fade_in_image_test.dart b/packages/flutter/test/widgets/fade_in_image_test.dart index d659fc5ff3..3b5c429f6c 100644 --- a/packages/flutter/test/widgets/fade_in_image_test.dart +++ b/packages/flutter/test/widgets/fade_in_image_test.dart @@ -43,14 +43,12 @@ class FadeInImageParts { } class FadeInImageElements { - const FadeInImageElements(this.rawImageElement, this.fadeTransitionElement); + const FadeInImageElements(this.rawImageElement); final Element rawImageElement; - final Element? fadeTransitionElement; RawImage get rawImage => rawImageElement.widget as RawImage; - FadeTransition? get fadeTransition => fadeTransitionElement?.widget as FadeTransition?; - double get opacity => fadeTransition == null ? 1 : fadeTransition!.opacity.value; + double get opacity => rawImage.opacity?.value ?? 1.0; } class LoadTestImageProvider extends ImageProvider { @@ -78,11 +76,8 @@ FadeInImageParts findFadeInImage(WidgetTester tester) { final Iterable rawImageElements = tester.elementList(find.byType(RawImage)); ComponentElement? fadeInImageElement; for (final Element rawImageElement in rawImageElements) { - Element? fadeTransitionElement; rawImageElement.visitAncestorElements((Element ancestor) { - if (ancestor.widget is FadeTransition) { - fadeTransitionElement = ancestor; - } else if (ancestor.widget is FadeInImage) { + if (ancestor.widget is FadeInImage) { if (fadeInImageElement == null) { fadeInImageElement = ancestor as ComponentElement; } else { @@ -93,7 +88,7 @@ FadeInImageParts findFadeInImage(WidgetTester tester) { return true; }); expect(fadeInImageElement, isNotNull); - elements.add(FadeInImageElements(rawImageElement, fadeTransitionElement)); + elements.add(FadeInImageElements(rawImageElement)); } if (elements.length == 2) { return FadeInImageParts(fadeInImageElement!, elements.last, elements.first); diff --git a/packages/flutter/test/widgets/image_test.dart b/packages/flutter/test/widgets/image_test.dart index ac1fc758b2..f195cd3f33 100644 --- a/packages/flutter/test/widgets/image_test.dart +++ b/packages/flutter/test/widgets/image_test.dart @@ -753,6 +753,19 @@ void main() { expect(renderer.colorBlendMode, BlendMode.clear); }); + testWidgets('Image opacity parameter', (WidgetTester tester) async { + const Animation opacity = AlwaysStoppedAnimation(0.5); + await tester.pumpWidget( + Image( + excludeFromSemantics: true, + image: _TestImageProvider(), + opacity: opacity, + ), + ); + final RenderImage renderer = tester.renderObject(find.byType(Image)); + expect(renderer.opacity, opacity); + }); + testWidgets('Precache', (WidgetTester tester) async { final _TestImageProvider provider = _TestImageProvider(); late Future precache; @@ -1721,6 +1734,57 @@ void main() { skip: kIsWeb, // https://github.com/flutter/flutter/issues/54292. ); + testWidgets( + 'Image opacity', + (WidgetTester tester) async { + final Key key = UniqueKey(); + await tester.pumpWidget(RepaintBoundary( + key: key, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceAround, + textDirection: TextDirection.ltr, + children: [ + Image.memory( + Uint8List.fromList(kBlueRectPng), + opacity: const AlwaysStoppedAnimation(0.25), + ), + Image.memory( + Uint8List.fromList(kBlueRectPng), + opacity: const AlwaysStoppedAnimation(0.5), + ), + Image.memory( + Uint8List.fromList(kBlueRectPng), + opacity: const AlwaysStoppedAnimation(0.75), + ), + Image.memory( + Uint8List.fromList(kBlueRectPng), + opacity: const AlwaysStoppedAnimation(1.0), + ), + ], + ), + )); + + // precacheImage is needed, or the image in the golden file will be empty. + if (!kIsWeb) { + final Finder allImages = find.byType(Image); + for (final Element e in allImages.evaluate()) { + await tester.runAsync(() async { + final Image image = e.widget as Image; + await precacheImage(image.image, e); + }); + } + await tester.pumpAndSettle(); + } + + await expectLater( + find.byKey(key), + matchesGoldenFile('transparent_image.png'), + ); + }, + skip: kIsWeb, // https://github.com/flutter/flutter/issues/54292. + ); + testWidgets('Reports image size when painted', (WidgetTester tester) async { late ImageSizeInfo imageSizeInfo; int count = 0;