Unverified Commit a84bb4eb authored by Dwayne Slater's avatar Dwayne Slater Committed by GitHub

Add ability to specify Image widget opacity as an animation (#83379)

parent 59f6cc7a
......@@ -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;
......
......@@ -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<double>? 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<double>? get opacity => _opacity;
Animation<double>? _opacity;
set opacity(Animation<double>? 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<Animation<double>?>('opacity', opacity, defaultValue: null));
properties.add(EnumProperty<BlendMode>('colorBlendMode', colorBlendMode, defaultValue: null));
properties.add(EnumProperty<BoxFit>('fit', fit, defaultValue: null));
properties.add(DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null));
......
......@@ -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<double>? 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<Animation<double>?>('opacity', opacity, defaultValue: null));
properties.add(EnumProperty<BlendMode>('colorBlendMode', colorBlendMode, defaultValue: null));
properties.add(EnumProperty<BoxFit>('fit', fit, defaultValue: null));
properties.add(DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null));
......
......@@ -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<FadeInImage> createState() => _FadeInImageState();
}
class _FadeInImageState extends State<FadeInImage> {
static const Animation<double> _kOpaqueAnimation = AlwaysStoppedAnimation<double>(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<double> 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<double> 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: <Widget>[
target,
FadeTransition(
opacity: _placeholderOpacityAnimation!,
child: widget.placeholder,
),
widget.target,
widget.placeholder,
],
);
}
......
......@@ -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<double>? 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<Animation<double>?>('opacity', opacity, defaultValue: null));
properties.add(EnumProperty<BlendMode>('colorBlendMode', colorBlendMode, defaultValue: null));
properties.add(EnumProperty<BoxFit>('fit', fit, defaultValue: null));
properties.add(DiagnosticsProperty<AlignmentGeometry>('alignment', alignment, defaultValue: null));
......@@ -1326,6 +1346,7 @@ class _ImageState extends State<Image> 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,
......
......@@ -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<Object> {
......@@ -78,11 +76,8 @@ FadeInImageParts findFadeInImage(WidgetTester tester) {
final Iterable<Element> 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);
......
......@@ -753,6 +753,19 @@ void main() {
expect(renderer.colorBlendMode, BlendMode.clear);
});
testWidgets('Image opacity parameter', (WidgetTester tester) async {
const Animation<double> opacity = AlwaysStoppedAnimation<double>(0.5);
await tester.pumpWidget(
Image(
excludeFromSemantics: true,
image: _TestImageProvider(),
opacity: opacity,
),
);
final RenderImage renderer = tester.renderObject<RenderImage>(find.byType(Image));
expect(renderer.opacity, opacity);
});
testWidgets('Precache', (WidgetTester tester) async {
final _TestImageProvider provider = _TestImageProvider();
late Future<void> 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: <Widget>[
Image.memory(
Uint8List.fromList(kBlueRectPng),
opacity: const AlwaysStoppedAnimation<double>(0.25),
),
Image.memory(
Uint8List.fromList(kBlueRectPng),
opacity: const AlwaysStoppedAnimation<double>(0.5),
),
Image.memory(
Uint8List.fromList(kBlueRectPng),
opacity: const AlwaysStoppedAnimation<double>(0.75),
),
Image.memory(
Uint8List.fromList(kBlueRectPng),
opacity: const AlwaysStoppedAnimation<double>(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;
......
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