Add ability to specify Image widget opacity as an animation (#83379)
This commit is contained in:
parent
59f6cc7ac9
commit
a84bb4eb3c
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user