Skip to content

Commit 58019b3

Browse files
authored
Add a new MatrixTransition and refactor ScaleTransition and RotationT… (#131084)
�ransition to derive from it. The MatrixTransition class uses a callback to handle any value => Matrix animation. The alignment and filterQuality logic that was in ScaleTransition and RotationTransition is now factored in MatrixTransition. The ScaleTransition.scale and RotationTransition.turns getters had to be kept because they're still referenced in https://github.com/flutter/packages/tree/main/packages/animations, and https://github.com/flutter/packages/flutter/test/. I plan to remove the references there, once this PR is generally available, and then remove the getters here. A RotationTransition test was updated to use matrixMoreOrLessEquals because using Matrix4.rotationZ doesn't have the special cases Transform.Rotation had, and zeroes in matrix weren't exactly zeroes. fixes #130946
1 parent 1cfba26 commit 58019b3

2 files changed

Lines changed: 215 additions & 88 deletions

File tree

packages/flutter/lib/src/widgets/transitions.dart

Lines changed: 87 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -225,51 +225,51 @@ class SlideTransition extends AnimatedWidget {
225225
}
226226
}
227227

228-
/// Animates the scale of a transformed widget.
228+
/// Signature for the callback to [MatrixTransition.onTransform].
229229
///
230-
/// Here's an illustration of the [ScaleTransition] widget, with it's [alignment]
231-
/// animated by a [CurvedAnimation] set to [Curves.fastOutSlowIn]:
232-
/// {@animation 300 378 https://flutter.github.io/assets-for-api-docs/assets/widgets/scale_transition.mp4}
233-
///
234-
/// {@tool dartpad}
235-
/// The following code implements the [ScaleTransition] as seen in the video
236-
/// above:
230+
/// Computes a [Matrix4] to be used in the [MatrixTransition] transformed widget
231+
/// from the [MatrixTransition.animation] value.
232+
typedef TransformCallback = Matrix4 Function(double animationValue);
233+
234+
/// Animates the [Matrix4] of a transformed widget.
237235
///
238-
/// ** See code in examples/api/lib/widgets/transitions/scale_transition.0.dart **
239-
/// {@end-tool}
236+
/// The [onTransform] callback computes a [Matrix4] from the animated value, it
237+
/// is called every time the [animation] changes its value.
240238
///
241239
/// See also:
242240
///
243-
/// * [PositionedTransition], a widget that animates its child from a start
244-
/// position to an end position over the lifetime of the animation.
245-
/// * [RelativePositionedTransition], a widget that transitions its child's
246-
/// position based on the value of a rectangle relative to a bounding box.
247-
/// * [SizeTransition], a widget that animates its own size and clips and
248-
/// aligns its child.
249-
class ScaleTransition extends AnimatedWidget {
250-
/// Creates a scale transition.
241+
/// * [ScaleTransition], which animates the scale of a widget, by providing a
242+
/// matrix which scales along the X and Y axis.
243+
/// * [RotationTransition], which animates the rotation of a widget, by
244+
/// providing a matrix which rotates along the Z axis.
245+
class MatrixTransition extends AnimatedWidget {
246+
/// Creates a matrix transition.
251247
///
252-
/// The [scale] argument must not be null. The [alignment] argument defaults
253-
/// to [Alignment.center].
254-
const ScaleTransition({
248+
/// The [alignment] argument defaults to [Alignment.center].
249+
const MatrixTransition({
255250
super.key,
256-
required Animation<double> scale,
251+
required Animation<double> animation,
252+
required this.onTransform,
257253
this.alignment = Alignment.center,
258254
this.filterQuality,
259255
this.child,
260-
}) : super(listenable: scale);
256+
}) : super(listenable: animation);
261257

262-
/// The animation that controls the scale of the child.
258+
/// The callback to compute a [Matrix4] from the [animation]. It's called
259+
/// every time [animation] changes its value.
260+
final TransformCallback onTransform;
261+
262+
/// The animation that controls the matrix of the child.
263263
///
264-
/// If the current value of the scale animation is v, the child will be
265-
/// painted v times its normal size.
266-
Animation<double> get scale => listenable as Animation<double>;
264+
/// The matrix will be computed from the animation with the [onTransform]
265+
/// callback.
266+
Animation<double> get animation => listenable as Animation<double>;
267267

268-
/// The alignment of the origin of the coordinate system in which the scale
269-
/// takes place, relative to the size of the box.
268+
/// The alignment of the origin of the coordinate system in which the
269+
/// transform takes place, relative to the size of the box.
270270
///
271-
/// For example, to set the origin of the scale to bottom middle, you can use
272-
/// an alignment of (0.0, 1.0).
271+
/// For example, to set the origin of the transform to bottom middle, you can
272+
/// use an alignment of (0.0, 1.0).
273273
final Alignment alignment;
274274

275275
/// The filter quality with which to apply the transform as a bitmap operation.
@@ -292,23 +292,66 @@ class ScaleTransition extends AnimatedWidget {
292292
// but leaving it in the layer tree before the animation has started or after
293293
// it has finished significantly hurts performance.
294294
final bool useFilterQuality;
295-
switch (scale.status) {
295+
switch (animation.status) {
296296
case AnimationStatus.dismissed:
297297
case AnimationStatus.completed:
298298
useFilterQuality = false;
299299
case AnimationStatus.forward:
300300
case AnimationStatus.reverse:
301301
useFilterQuality = true;
302302
}
303-
return Transform.scale(
304-
scale: scale.value,
303+
return Transform(
304+
transform: onTransform(animation.value),
305305
alignment: alignment,
306306
filterQuality: useFilterQuality ? filterQuality : null,
307307
child: child,
308308
);
309309
}
310310
}
311311

312+
/// Animates the scale of a transformed widget.
313+
///
314+
/// Here's an illustration of the [ScaleTransition] widget, with it's [alignment]
315+
/// animated by a [CurvedAnimation] set to [Curves.fastOutSlowIn]:
316+
/// {@animation 300 378 https://flutter.github.io/assets-for-api-docs/assets/widgets/scale_transition.mp4}
317+
///
318+
/// {@tool dartpad}
319+
/// The following code implements the [ScaleTransition] as seen in the video
320+
/// above:
321+
///
322+
/// ** See code in examples/api/lib/widgets/transitions/scale_transition.0.dart **
323+
/// {@end-tool}
324+
///
325+
/// See also:
326+
///
327+
/// * [PositionedTransition], a widget that animates its child from a start
328+
/// position to an end position over the lifetime of the animation.
329+
/// * [RelativePositionedTransition], a widget that transitions its child's
330+
/// position based on the value of a rectangle relative to a bounding box.
331+
/// * [SizeTransition], a widget that animates its own size and clips and
332+
/// aligns its child.
333+
class ScaleTransition extends MatrixTransition {
334+
/// Creates a scale transition.
335+
///
336+
/// The [alignment] argument defaults to [Alignment.center].
337+
const ScaleTransition({
338+
super.key,
339+
required Animation<double> scale,
340+
super.alignment = Alignment.center,
341+
super.filterQuality,
342+
super.child,
343+
}) : super(animation: scale, onTransform: _handleScaleMatrix);
344+
345+
/// The animation that controls the scale of the child.
346+
Animation<double> get scale => animation;
347+
348+
/// The callback that controls the scale of the child.
349+
///
350+
/// If the current value of the animation is v, the child will be
351+
/// painted v times its normal size.
352+
static Matrix4 _handleScaleMatrix(double value) => Matrix4.diagonal3Values(value, value, 1.0);
353+
}
354+
312355
/// Animates the rotation of a widget.
313356
///
314357
/// Here's an illustration of the [RotationTransition] widget, with it's [turns]
@@ -328,66 +371,26 @@ class ScaleTransition extends AnimatedWidget {
328371
/// widget.
329372
/// * [SizeTransition], a widget that animates its own size and clips and
330373
/// aligns its child.
331-
class RotationTransition extends AnimatedWidget {
374+
class RotationTransition extends MatrixTransition {
332375
/// Creates a rotation transition.
333376
///
334377
/// The [turns] argument must not be null.
335378
const RotationTransition({
336379
super.key,
337380
required Animation<double> turns,
338-
this.alignment = Alignment.center,
339-
this.filterQuality,
340-
this.child,
341-
}) : super(listenable: turns);
381+
super.alignment = Alignment.center,
382+
super.filterQuality,
383+
super.child,
384+
}) : super(animation: turns, onTransform: _handleTurnsMatrix);
342385

343386
/// The animation that controls the rotation of the child.
344-
///
345-
/// If the current value of the turns animation is v, the child will be
346-
/// rotated v * 2 * pi radians before being painted.
347-
Animation<double> get turns => listenable as Animation<double>;
348-
349-
/// The alignment of the origin of the coordinate system around which the
350-
/// rotation occurs, relative to the size of the box.
351-
///
352-
/// For example, to set the origin of the rotation to top right corner, use
353-
/// an alignment of (1.0, -1.0) or use [Alignment.topRight]
354-
final Alignment alignment;
355-
356-
/// The filter quality with which to apply the transform as a bitmap operation.
357-
///
358-
/// When the animation is stopped (either in [AnimationStatus.dismissed] or
359-
/// [AnimationStatus.completed]), the filter quality argument will be ignored.
360-
///
361-
/// {@macro flutter.widgets.Transform.optional.FilterQuality}
362-
final FilterQuality? filterQuality;
387+
Animation<double> get turns => animation;
363388

364-
/// The widget below this widget in the tree.
389+
/// The callback that controls the rotation of the child.
365390
///
366-
/// {@macro flutter.widgets.ProxyWidget.child}
367-
final Widget? child;
368-
369-
@override
370-
Widget build(BuildContext context) {
371-
// The ImageFilter layer created by setting filterQuality will introduce
372-
// a saveLayer call. This is usually worthwhile when animating the layer,
373-
// but leaving it in the layer tree before the animation has started or after
374-
// it has finished significantly hurts performance.
375-
final bool useFilterQuality;
376-
switch (turns.status) {
377-
case AnimationStatus.dismissed:
378-
case AnimationStatus.completed:
379-
useFilterQuality = false;
380-
case AnimationStatus.forward:
381-
case AnimationStatus.reverse:
382-
useFilterQuality = true;
383-
}
384-
return Transform.rotate(
385-
angle: turns.value * math.pi * 2.0,
386-
alignment: alignment,
387-
filterQuality: useFilterQuality ? filterQuality : null,
388-
child: child,
389-
);
390-
}
391+
/// If the current value of the animation is v, the child will be rotated
392+
/// v * 2 * pi radians before being painted.
393+
static Matrix4 _handleTurnsMatrix(double value) => Matrix4.rotationZ(value * math.pi * 2.0);
391394
}
392395

393396
/// Animates its own size and clips and aligns its child.

packages/flutter/test/widgets/transitions_test.dart

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,67 @@ void main() {
296296
expect(actualPositionedBox.widthFactor, 1.0);
297297
});
298298

299+
testWidgets('MatrixTransition animates', (WidgetTester tester) async {
300+
final AnimationController controller = AnimationController(vsync: const TestVSync());
301+
final Widget widget = MatrixTransition(
302+
alignment: Alignment.topRight,
303+
onTransform: (double value) => Matrix4.translationValues(value, value, value),
304+
animation: controller,
305+
child: const Text(
306+
'Matrix',
307+
textDirection: TextDirection.ltr,
308+
),
309+
);
310+
311+
await tester.pumpWidget(widget);
312+
Transform actualTransformedBox = tester.widget(find.byType(Transform));
313+
Matrix4 actualTransform = actualTransformedBox.transform;
314+
expect(actualTransform, equals(Matrix4.rotationZ(0.0)));
315+
316+
controller.value = 0.5;
317+
await tester.pump();
318+
actualTransformedBox = tester.widget(find.byType(Transform));
319+
actualTransform = actualTransformedBox.transform;
320+
expect(actualTransform, Matrix4.fromList(<double>[
321+
1.0, 0.0, 0.0, 0.5,
322+
0.0, 1.0, 0.0, 0.5,
323+
0.0, 0.0, 1.0, 0.5,
324+
0.0, 0.0, 0.0, 1.0,
325+
])..transpose());
326+
327+
controller.value = 0.75;
328+
await tester.pump();
329+
actualTransformedBox = tester.widget(find.byType(Transform));
330+
actualTransform = actualTransformedBox.transform;
331+
expect(actualTransform, Matrix4.fromList(<double>[
332+
1.0, 0.0, 0.0, 0.75,
333+
0.0, 1.0, 0.0, 0.75,
334+
0.0, 0.0, 1.0, 0.75,
335+
0.0, 0.0, 0.0, 1.0,
336+
])..transpose());
337+
});
338+
339+
testWidgets('MatrixTransition maintains chosen alignment during animation', (WidgetTester tester) async {
340+
final AnimationController controller = AnimationController(vsync: const TestVSync());
341+
final Widget widget = MatrixTransition(
342+
alignment: Alignment.topRight,
343+
onTransform: (double value) => Matrix4.identity(),
344+
animation: controller,
345+
child: const Text('Matrix', textDirection: TextDirection.ltr),
346+
);
347+
348+
await tester.pumpWidget(widget);
349+
MatrixTransition actualTransformedBox = tester.widget(find.byType(MatrixTransition));
350+
Alignment actualAlignment = actualTransformedBox.alignment;
351+
expect(actualAlignment, Alignment.topRight);
352+
353+
controller.value = 0.5;
354+
await tester.pump();
355+
actualTransformedBox = tester.widget(find.byType(MatrixTransition));
356+
actualAlignment = actualTransformedBox.alignment;
357+
expect(actualAlignment, Alignment.topRight);
358+
});
359+
299360
testWidgets('RotationTransition animates', (WidgetTester tester) async {
300361
final AnimationController controller = AnimationController(vsync: const TestVSync());
301362
final Widget widget = RotationTransition(
@@ -316,23 +377,23 @@ void main() {
316377
await tester.pump();
317378
actualRotatedBox = tester.widget(find.byType(Transform));
318379
actualTurns = actualRotatedBox.transform;
319-
expect(actualTurns, Matrix4.fromList(<double>[
380+
expect(actualTurns, matrixMoreOrLessEquals(Matrix4.fromList(<double>[
320381
-1.0, 0.0, 0.0, 0.0,
321382
0.0, -1.0, 0.0, 0.0,
322383
0.0, 0.0, 1.0, 0.0,
323384
0.0, 0.0, 0.0, 1.0,
324-
])..transpose());
385+
])..transpose()));
325386

326387
controller.value = 0.75;
327388
await tester.pump();
328389
actualRotatedBox = tester.widget(find.byType(Transform));
329390
actualTurns = actualRotatedBox.transform;
330-
expect(actualTurns, Matrix4.fromList(<double>[
391+
expect(actualTurns, matrixMoreOrLessEquals(Matrix4.fromList(<double>[
331392
0.0, 1.0, 0.0, 0.0,
332393
-1.0, 0.0, 0.0, 0.0,
333394
0.0, 0.0, 1.0, 0.0,
334395
0.0, 0.0, 0.0, 1.0,
335-
])..transpose());
396+
])..transpose()));
336397
});
337398

338399
testWidgets('RotationTransition maintains chosen alignment during animation', (WidgetTester tester) async {
@@ -457,6 +518,69 @@ void main() {
457518
});
458519
});
459520

521+
group('MatrixTransition', () {
522+
testWidgets('uses ImageFilter when provided with FilterQuality argument', (WidgetTester tester) async {
523+
final AnimationController controller = AnimationController(vsync: const TestVSync());
524+
final Animation<double> animation = Tween<double>(begin: 0.0, end: 1.0).animate(controller);
525+
final Widget widget = Directionality(
526+
textDirection: TextDirection.ltr,
527+
child: MatrixTransition(
528+
animation: animation,
529+
onTransform: (double value) => Matrix4.identity(),
530+
filterQuality: FilterQuality.none,
531+
child: const Text('Matrix Transition'),
532+
),
533+
);
534+
535+
await tester.pumpWidget(widget);
536+
537+
// Validate that expensive layer is not left in tree before animation has started.
538+
expect(tester.layers, isNot(contains(isA<ImageFilterLayer>())));
539+
540+
controller.value = 0.25;
541+
await tester.pump();
542+
543+
expect(
544+
tester.layers,
545+
contains(isA<ImageFilterLayer>().having(
546+
(ImageFilterLayer layer) => layer.imageFilter.toString(),
547+
'image filter',
548+
startsWith('ImageFilter.matrix('),
549+
)),
550+
);
551+
552+
controller.value = 0.5;
553+
await tester.pump();
554+
555+
expect(
556+
tester.layers,
557+
contains(isA<ImageFilterLayer>().having(
558+
(ImageFilterLayer layer) => layer.imageFilter.toString(),
559+
'image filter',
560+
startsWith('ImageFilter.matrix('),
561+
)),
562+
);
563+
564+
controller.value = 0.75;
565+
await tester.pump();
566+
567+
expect(
568+
tester.layers,
569+
contains(isA<ImageFilterLayer>().having(
570+
(ImageFilterLayer layer) => layer.imageFilter.toString(),
571+
'image filter',
572+
startsWith('ImageFilter.matrix('),
573+
)),
574+
);
575+
576+
controller.value = 1;
577+
await tester.pump();
578+
579+
// Validate that expensive layer is not left in tree after animation has finished.
580+
expect(tester.layers, isNot(contains(isA<ImageFilterLayer>())));
581+
});
582+
});
583+
460584
group('ScaleTransition', () {
461585
testWidgets('uses ImageFilter when provided with FilterQuality argument', (WidgetTester tester) async {
462586
final AnimationController controller = AnimationController(vsync: const TestVSync());

0 commit comments

Comments
 (0)