diff --git a/packages/flutter/lib/src/widgets/transitions.dart b/packages/flutter/lib/src/widgets/transitions.dart index bdde5fe12f..b91f4fb2ad 100644 --- a/packages/flutter/lib/src/widgets/transitions.dart +++ b/packages/flutter/lib/src/widgets/transitions.dart @@ -225,6 +225,90 @@ class SlideTransition extends AnimatedWidget { } } +/// Signature for the callback to [MatrixTransition.onTransform]. +/// +/// Computes a [Matrix4] to be used in the [MatrixTransition] transformed widget +/// from the [MatrixTransition.animation] value. +typedef TransformCallback = Matrix4 Function(double animationValue); + +/// Animates the [Matrix4] of a transformed widget. +/// +/// The [onTransform] callback computes a [Matrix4] from the animated value, it +/// is called every time the [animation] changes its value. +/// +/// See also: +/// +/// * [ScaleTransition], which animates the scale of a widget, by providing a +/// matrix which scales along the X and Y axis. +/// * [RotationTransition], which animates the rotation of a widget, by +/// providing a matrix which rotates along the Z axis. +class MatrixTransition extends AnimatedWidget { + /// Creates a matrix transition. + /// + /// The [alignment] argument defaults to [Alignment.center]. + const MatrixTransition({ + super.key, + required Animation animation, + required this.onTransform, + this.alignment = Alignment.center, + this.filterQuality, + this.child, + }) : super(listenable: animation); + + /// The callback to compute a [Matrix4] from the [animation]. It's called + /// every time [animation] changes its value. + final TransformCallback onTransform; + + /// The animation that controls the matrix of the child. + /// + /// The matrix will be computed from the animation with the [onTransform] + /// callback. + Animation get animation => listenable as Animation; + + /// The alignment of the origin of the coordinate system in which the + /// transform takes place, relative to the size of the box. + /// + /// For example, to set the origin of the transform to bottom middle, you can + /// use an alignment of (0.0, 1.0). + final Alignment alignment; + + /// The filter quality with which to apply the transform as a bitmap operation. + /// + /// When the animation is stopped (either in [AnimationStatus.dismissed] or + /// [AnimationStatus.completed]), the filter quality argument will be ignored. + /// + /// {@macro flutter.widgets.Transform.optional.FilterQuality} + final FilterQuality? filterQuality; + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + @override + Widget build(BuildContext context) { + // The ImageFilter layer created by setting filterQuality will introduce + // a saveLayer call. This is usually worthwhile when animating the layer, + // but leaving it in the layer tree before the animation has started or after + // it has finished significantly hurts performance. + final bool useFilterQuality; + switch (animation.status) { + case AnimationStatus.dismissed: + case AnimationStatus.completed: + useFilterQuality = false; + case AnimationStatus.forward: + case AnimationStatus.reverse: + useFilterQuality = true; + } + return Transform( + transform: onTransform(animation.value), + alignment: alignment, + filterQuality: useFilterQuality ? filterQuality : null, + child: child, + ); + } +} + /// Animates the scale of a transformed widget. /// /// Here's an illustration of the [ScaleTransition] widget, with it's [alignment] @@ -246,67 +330,26 @@ class SlideTransition extends AnimatedWidget { /// position based on the value of a rectangle relative to a bounding box. /// * [SizeTransition], a widget that animates its own size and clips and /// aligns its child. -class ScaleTransition extends AnimatedWidget { +class ScaleTransition extends MatrixTransition { /// Creates a scale transition. /// - /// The [scale] argument must not be null. The [alignment] argument defaults - /// to [Alignment.center]. + /// The [alignment] argument defaults to [Alignment.center]. const ScaleTransition({ super.key, required Animation scale, - this.alignment = Alignment.center, - this.filterQuality, - this.child, - }) : super(listenable: scale); + super.alignment = Alignment.center, + super.filterQuality, + super.child, + }) : super(animation: scale, onTransform: _handleScaleMatrix); /// The animation that controls the scale of the child. + Animation get scale => animation; + + /// The callback that controls the scale of the child. /// - /// If the current value of the scale animation is v, the child will be + /// If the current value of the animation is v, the child will be /// painted v times its normal size. - Animation get scale => listenable as Animation; - - /// The alignment of the origin of the coordinate system in which the scale - /// takes place, relative to the size of the box. - /// - /// For example, to set the origin of the scale to bottom middle, you can use - /// an alignment of (0.0, 1.0). - final Alignment alignment; - - /// The filter quality with which to apply the transform as a bitmap operation. - /// - /// When the animation is stopped (either in [AnimationStatus.dismissed] or - /// [AnimationStatus.completed]), the filter quality argument will be ignored. - /// - /// {@macro flutter.widgets.Transform.optional.FilterQuality} - final FilterQuality? filterQuality; - - /// The widget below this widget in the tree. - /// - /// {@macro flutter.widgets.ProxyWidget.child} - final Widget? child; - - @override - Widget build(BuildContext context) { - // The ImageFilter layer created by setting filterQuality will introduce - // a saveLayer call. This is usually worthwhile when animating the layer, - // but leaving it in the layer tree before the animation has started or after - // it has finished significantly hurts performance. - final bool useFilterQuality; - switch (scale.status) { - case AnimationStatus.dismissed: - case AnimationStatus.completed: - useFilterQuality = false; - case AnimationStatus.forward: - case AnimationStatus.reverse: - useFilterQuality = true; - } - return Transform.scale( - scale: scale.value, - alignment: alignment, - filterQuality: useFilterQuality ? filterQuality : null, - child: child, - ); - } + static Matrix4 _handleScaleMatrix(double value) => Matrix4.diagonal3Values(value, value, 1.0); } /// Animates the rotation of a widget. @@ -328,66 +371,26 @@ class ScaleTransition extends AnimatedWidget { /// widget. /// * [SizeTransition], a widget that animates its own size and clips and /// aligns its child. -class RotationTransition extends AnimatedWidget { +class RotationTransition extends MatrixTransition { /// Creates a rotation transition. /// /// The [turns] argument must not be null. const RotationTransition({ super.key, required Animation turns, - this.alignment = Alignment.center, - this.filterQuality, - this.child, - }) : super(listenable: turns); + super.alignment = Alignment.center, + super.filterQuality, + super.child, + }) : super(animation: turns, onTransform: _handleTurnsMatrix); /// The animation that controls the rotation of the child. - /// - /// If the current value of the turns animation is v, the child will be - /// rotated v * 2 * pi radians before being painted. - Animation get turns => listenable as Animation; + Animation get turns => animation; - /// The alignment of the origin of the coordinate system around which the - /// rotation occurs, relative to the size of the box. + /// The callback that controls the rotation of the child. /// - /// For example, to set the origin of the rotation to top right corner, use - /// an alignment of (1.0, -1.0) or use [Alignment.topRight] - final Alignment alignment; - - /// The filter quality with which to apply the transform as a bitmap operation. - /// - /// When the animation is stopped (either in [AnimationStatus.dismissed] or - /// [AnimationStatus.completed]), the filter quality argument will be ignored. - /// - /// {@macro flutter.widgets.Transform.optional.FilterQuality} - final FilterQuality? filterQuality; - - /// The widget below this widget in the tree. - /// - /// {@macro flutter.widgets.ProxyWidget.child} - final Widget? child; - - @override - Widget build(BuildContext context) { - // The ImageFilter layer created by setting filterQuality will introduce - // a saveLayer call. This is usually worthwhile when animating the layer, - // but leaving it in the layer tree before the animation has started or after - // it has finished significantly hurts performance. - final bool useFilterQuality; - switch (turns.status) { - case AnimationStatus.dismissed: - case AnimationStatus.completed: - useFilterQuality = false; - case AnimationStatus.forward: - case AnimationStatus.reverse: - useFilterQuality = true; - } - return Transform.rotate( - angle: turns.value * math.pi * 2.0, - alignment: alignment, - filterQuality: useFilterQuality ? filterQuality : null, - child: child, - ); - } + /// If the current value of the animation is v, the child will be rotated + /// v * 2 * pi radians before being painted. + static Matrix4 _handleTurnsMatrix(double value) => Matrix4.rotationZ(value * math.pi * 2.0); } /// Animates its own size and clips and aligns its child. diff --git a/packages/flutter/test/widgets/transitions_test.dart b/packages/flutter/test/widgets/transitions_test.dart index 9b1a33bc9a..9af19cf513 100644 --- a/packages/flutter/test/widgets/transitions_test.dart +++ b/packages/flutter/test/widgets/transitions_test.dart @@ -296,6 +296,67 @@ void main() { expect(actualPositionedBox.widthFactor, 1.0); }); + testWidgets('MatrixTransition animates', (WidgetTester tester) async { + final AnimationController controller = AnimationController(vsync: const TestVSync()); + final Widget widget = MatrixTransition( + alignment: Alignment.topRight, + onTransform: (double value) => Matrix4.translationValues(value, value, value), + animation: controller, + child: const Text( + 'Matrix', + textDirection: TextDirection.ltr, + ), + ); + + await tester.pumpWidget(widget); + Transform actualTransformedBox = tester.widget(find.byType(Transform)); + Matrix4 actualTransform = actualTransformedBox.transform; + expect(actualTransform, equals(Matrix4.rotationZ(0.0))); + + controller.value = 0.5; + await tester.pump(); + actualTransformedBox = tester.widget(find.byType(Transform)); + actualTransform = actualTransformedBox.transform; + expect(actualTransform, Matrix4.fromList([ + 1.0, 0.0, 0.0, 0.5, + 0.0, 1.0, 0.0, 0.5, + 0.0, 0.0, 1.0, 0.5, + 0.0, 0.0, 0.0, 1.0, + ])..transpose()); + + controller.value = 0.75; + await tester.pump(); + actualTransformedBox = tester.widget(find.byType(Transform)); + actualTransform = actualTransformedBox.transform; + expect(actualTransform, Matrix4.fromList([ + 1.0, 0.0, 0.0, 0.75, + 0.0, 1.0, 0.0, 0.75, + 0.0, 0.0, 1.0, 0.75, + 0.0, 0.0, 0.0, 1.0, + ])..transpose()); + }); + + testWidgets('MatrixTransition maintains chosen alignment during animation', (WidgetTester tester) async { + final AnimationController controller = AnimationController(vsync: const TestVSync()); + final Widget widget = MatrixTransition( + alignment: Alignment.topRight, + onTransform: (double value) => Matrix4.identity(), + animation: controller, + child: const Text('Matrix', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget(widget); + MatrixTransition actualTransformedBox = tester.widget(find.byType(MatrixTransition)); + Alignment actualAlignment = actualTransformedBox.alignment; + expect(actualAlignment, Alignment.topRight); + + controller.value = 0.5; + await tester.pump(); + actualTransformedBox = tester.widget(find.byType(MatrixTransition)); + actualAlignment = actualTransformedBox.alignment; + expect(actualAlignment, Alignment.topRight); + }); + testWidgets('RotationTransition animates', (WidgetTester tester) async { final AnimationController controller = AnimationController(vsync: const TestVSync()); final Widget widget = RotationTransition( @@ -316,23 +377,23 @@ void main() { await tester.pump(); actualRotatedBox = tester.widget(find.byType(Transform)); actualTurns = actualRotatedBox.transform; - expect(actualTurns, Matrix4.fromList([ + expect(actualTurns, matrixMoreOrLessEquals(Matrix4.fromList([ -1.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, - ])..transpose()); + ])..transpose())); controller.value = 0.75; await tester.pump(); actualRotatedBox = tester.widget(find.byType(Transform)); actualTurns = actualRotatedBox.transform; - expect(actualTurns, Matrix4.fromList([ + expect(actualTurns, matrixMoreOrLessEquals(Matrix4.fromList([ 0.0, 1.0, 0.0, 0.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, - ])..transpose()); + ])..transpose())); }); testWidgets('RotationTransition maintains chosen alignment during animation', (WidgetTester tester) async { @@ -457,6 +518,69 @@ void main() { }); }); + group('MatrixTransition', () { + testWidgets('uses ImageFilter when provided with FilterQuality argument', (WidgetTester tester) async { + final AnimationController controller = AnimationController(vsync: const TestVSync()); + final Animation animation = Tween(begin: 0.0, end: 1.0).animate(controller); + final Widget widget = Directionality( + textDirection: TextDirection.ltr, + child: MatrixTransition( + animation: animation, + onTransform: (double value) => Matrix4.identity(), + filterQuality: FilterQuality.none, + child: const Text('Matrix Transition'), + ), + ); + + await tester.pumpWidget(widget); + + // Validate that expensive layer is not left in tree before animation has started. + expect(tester.layers, isNot(contains(isA()))); + + controller.value = 0.25; + await tester.pump(); + + expect( + tester.layers, + contains(isA().having( + (ImageFilterLayer layer) => layer.imageFilter.toString(), + 'image filter', + startsWith('ImageFilter.matrix('), + )), + ); + + controller.value = 0.5; + await tester.pump(); + + expect( + tester.layers, + contains(isA().having( + (ImageFilterLayer layer) => layer.imageFilter.toString(), + 'image filter', + startsWith('ImageFilter.matrix('), + )), + ); + + controller.value = 0.75; + await tester.pump(); + + expect( + tester.layers, + contains(isA().having( + (ImageFilterLayer layer) => layer.imageFilter.toString(), + 'image filter', + startsWith('ImageFilter.matrix('), + )), + ); + + controller.value = 1; + await tester.pump(); + + // Validate that expensive layer is not left in tree after animation has finished. + expect(tester.layers, isNot(contains(isA()))); + }); + }); + group('ScaleTransition', () { testWidgets('uses ImageFilter when provided with FilterQuality argument', (WidgetTester tester) async { final AnimationController controller = AnimationController(vsync: const TestVSync());