Unverified Commit 64727975 authored by Jim Graham's avatar Jim Graham Committed by GitHub

add AnimatedScale and AnimatedRotation widgets (#83428)

parent d5710dfa
......@@ -255,6 +255,8 @@ class TextStyleTween extends Tween<TextStyle> {
/// [Container].
/// * [AnimatedDefaultTextStyle], which is an implicitly animated version of
/// [DefaultTextStyle].
/// * [AnimatedScale], which is an implicitly animated version of [Transform.scale].
/// * [AnimatedRotation], which is an implicitly animated version of [Transform.rotate].
/// * [AnimatedOpacity], which is an implicitly animated version of [Opacity].
/// * [AnimatedPadding], which is an implicitly animated version of [Padding].
/// * [AnimatedPhysicalModel], which is an implicitly animated version of
......@@ -1410,6 +1412,265 @@ class _AnimatedPositionedDirectionalState extends AnimatedWidgetBaseState<Animat
}
}
/// Animated version of [Transform.scale] which automatically transitions the child's
/// scale over a given duration whenever the given scale changes.
///
/// {@tool snippet}
///
/// This code defines a widget that uses [AnimatedScale] to change the size
/// of [FlutterLogo] gradually to a new scale whenever the button is pressed.
///
/// ```dart
/// class LogoScale extends StatefulWidget {
/// const LogoScale({Key? key}) : super(key: key);
///
/// @override
/// State<LogoScale> createState() => LogoScaleState();
/// }
///
/// class LogoScaleState extends State<LogoScale> {
/// double scale = 1.0;
///
/// void _changeScale() {
/// setState(() => scale = scale == 1.0 ? 3.0 : 1.0);
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Column(
/// mainAxisAlignment: MainAxisAlignment.center,
/// children: <Widget>[
/// ElevatedButton(
/// child: const Text('Scale Logo'),
/// onPressed: _changeScale,
/// ),
/// Padding(
/// padding: const EdgeInsets.all(50),
/// child: AnimatedScale(
/// scale: scale,
/// duration: const Duration(seconds: 2),
/// child: const FlutterLogo(),
/// ),
/// ),
/// ],
/// );
/// }
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [AnimatedRotation], for animating the rotation of a child.
/// * [AnimatedSize], for animating the resize of a child based on changes
/// in layout.
/// * [ScaleTransition], an explicitly animated version of this widget, where
/// an [Animation] is provided by the caller instead of being built in.
class AnimatedScale extends ImplicitlyAnimatedWidget {
/// Creates a widget that animates its scale implicitly.
///
/// The [scale] argument must not be null.
/// The [curve] and [duration] arguments must not be null.
const AnimatedScale({
Key? key,
this.child,
required this.scale,
this.alignment = Alignment.center,
this.filterQuality,
Curve curve = Curves.linear,
required Duration duration,
VoidCallback? onEnd,
}) : assert(scale != null),
super(key: key, curve: curve, duration: duration, onEnd: onEnd);
/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget? child;
/// The target scale.
///
/// The scale must not be null.
final double scale;
/// 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.
///
/// {@macro flutter.widgets.Transform.optional.FilterQuality}
final FilterQuality? filterQuality;
@override
ImplicitlyAnimatedWidgetState<AnimatedScale> createState() => _AnimatedScaleState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('scale', scale));
properties.add(DiagnosticsProperty<Alignment>('alignment', alignment, defaultValue: Alignment.center));
properties.add(EnumProperty<FilterQuality>('filterQuality', filterQuality, defaultValue: null));
}
}
class _AnimatedScaleState extends ImplicitlyAnimatedWidgetState<AnimatedScale> {
Tween<double>? _scale;
late Animation<double> _scaleAnimation;
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_scale = visitor(_scale, widget.scale, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
}
@override
void didUpdateTweens() {
_scaleAnimation = animation.drive(_scale!);
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _scaleAnimation,
alignment: widget.alignment,
filterQuality: widget.filterQuality,
child: widget.child,
);
}
}
/// Animated version of [Transform.rotate] which automatically transitions the child's
/// rotation over a given duration whenever the given rotation changes.
///
/// {@tool snippet}
///
/// This code defines a widget that uses [AnimatedRotation] to rotate a [FlutterLogo]
/// gradually by an eighth of a turn (45 degrees) with each press of the button.
///
/// ```dart
/// class LogoRotate extends StatefulWidget {
/// const LogoRotate({Key? key}) : super(key: key);
///
/// @override
/// State<LogoRotate> createState() => LogoRotateState();
/// }
///
/// class LogoRotateState extends State<LogoRotate> {
/// double turns = 0.0;
///
/// void _changeRotation() {
/// setState(() => turns += 1.0 / 8.0);
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Column(
/// mainAxisAlignment: MainAxisAlignment.center,
/// children: <Widget>[
/// ElevatedButton(
/// child: const Text('Rotate Logo'),
/// onPressed: _changeRotation,
/// ),
/// Padding(
/// padding: const EdgeInsets.all(50),
/// child: AnimatedRotation(
/// turns: turns,
/// duration: const Duration(seconds: 1),
/// child: const FlutterLogo(),
/// ),
/// ),
/// ],
/// );
/// }
/// }
/// ```
/// {@end-tool}
///
/// See also:
///
/// * [AnimatedScale], for animating the scale of a child.
/// * [RotationTransition], an explicitly animated version of this widget, where
/// an [Animation] is provided by the caller instead of being built in.
class AnimatedRotation extends ImplicitlyAnimatedWidget {
/// Creates a widget that animates its rotation implicitly.
///
/// The [turns] argument must not be null.
/// The [curve] and [duration] arguments must not be null.
const AnimatedRotation({
Key? key,
this.child,
required this.turns,
this.alignment = Alignment.center,
this.filterQuality,
Curve curve = Curves.linear,
required Duration duration,
VoidCallback? onEnd,
}) : assert(turns != null),
super(key: key, curve: curve, duration: duration, onEnd: onEnd);
/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget? child;
/// 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.
final double turns;
/// The alignment of the origin of the coordinate system in which the rotation
/// takes place, relative to the size of the box.
///
/// For example, to set the origin of the rotation 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.
///
/// {@macro flutter.widgets.Transform.optional.FilterQuality}
final FilterQuality? filterQuality;
@override
ImplicitlyAnimatedWidgetState<AnimatedRotation> createState() => _AnimatedRotationState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('turns', turns));
properties.add(DiagnosticsProperty<Alignment>('alignment', alignment, defaultValue: Alignment.center));
properties.add(EnumProperty<FilterQuality>('filterQuality', filterQuality, defaultValue: null));
}
}
class _AnimatedRotationState extends ImplicitlyAnimatedWidgetState<AnimatedRotation> {
Tween<double>? _turns;
late Animation<double> _turnsAnimation;
@override
void forEachTween(TweenVisitor<dynamic> visitor) {
_turns = visitor(_turns, widget.turns, (dynamic value) => Tween<double>(begin: value as double)) as Tween<double>?;
}
@override
void didUpdateTweens() {
_turnsAnimation = animation.drive(_turns!);
}
@override
Widget build(BuildContext context) {
return RotationTransition(
turns: _turnsAnimation,
alignment: widget.alignment,
filterQuality: widget.filterQuality,
child: widget.child,
);
}
}
/// Animated version of [Opacity] which automatically transitions the child's
/// opacity over a given duration whenever the given opacity changes.
///
......
......@@ -171,6 +171,122 @@ void main() {
expect(mockOnEndFunction.called, 1);
});
testWidgets('AnimatedScale onEnd callback test', (WidgetTester tester) async {
await tester.pumpWidget(wrap(
child: TestAnimatedWidget(
callback: mockOnEndFunction.handler,
switchKey: switchKey,
state: _TestAnimatedScaleWidgetState(),
),
));
final Finder widgetFinder = find.byKey(switchKey);
await tester.tap(widgetFinder);
await tester.pump();
expect(mockOnEndFunction.called, 0);
await tester.pump(animationDuration);
expect(mockOnEndFunction.called, 0);
await tester.pump(additionalDelay);
expect(mockOnEndFunction.called, 1);
});
testWidgets('AnimatedScale transition test', (WidgetTester tester) async {
await tester.pumpWidget(wrap(
child: TestAnimatedWidget(
switchKey: switchKey,
state: _TestAnimatedScaleWidgetState(),
),
));
final RebuildCountingState<StatefulWidget> state = tester.widget<TestAnimatedWidget>(
find.byType(TestAnimatedWidget)
).rebuildState!;
final Finder switchFinder = find.byKey(switchKey);
final ScaleTransition scaleWidget = tester.widget<ScaleTransition>(
find.ancestor(
of: find.byType(Placeholder),
matching: find.byType(ScaleTransition),
).first,
);
expect(state.builds, equals(1));
await tester.tap(switchFinder);
expect(state.builds, equals(1));
await tester.pump();
expect(scaleWidget.scale.value, equals(1.0));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 500));
expect(scaleWidget.scale.value, equals(1.5));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 250));
expect(scaleWidget.scale.value, equals(1.75));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 250));
expect(scaleWidget.scale.value, equals(2.0));
expect(state.builds, equals(2));
});
testWidgets('AnimatedRotation onEnd callback test', (WidgetTester tester) async {
await tester.pumpWidget(wrap(
child: TestAnimatedWidget(
callback: mockOnEndFunction.handler,
switchKey: switchKey,
state: _TestAnimatedRotationWidgetState(),
),
));
final Finder widgetFinder = find.byKey(switchKey);
await tester.tap(widgetFinder);
await tester.pump();
expect(mockOnEndFunction.called, 0);
await tester.pump(animationDuration);
expect(mockOnEndFunction.called, 0);
await tester.pump(additionalDelay);
expect(mockOnEndFunction.called, 1);
});
testWidgets('AnimatedRotation transition test', (WidgetTester tester) async {
await tester.pumpWidget(wrap(
child: TestAnimatedWidget(
switchKey: switchKey,
state: _TestAnimatedRotationWidgetState(),
),
));
final RebuildCountingState<StatefulWidget> state = tester.widget<TestAnimatedWidget>(
find.byType(TestAnimatedWidget)
).rebuildState!;
final Finder switchFinder = find.byKey(switchKey);
final RotationTransition rotationWidget = tester.widget<RotationTransition>(
find.ancestor(
of: find.byType(Placeholder),
matching: find.byType(RotationTransition),
).first,
);
expect(state.builds, equals(1));
await tester.tap(switchFinder);
expect(state.builds, equals(1));
await tester.pump();
expect(rotationWidget.turns.value, equals(0.0));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 500));
expect(rotationWidget.turns.value, equals(0.75));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 250));
expect(rotationWidget.turns.value, equals(1.125));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 250));
expect(rotationWidget.turns.value, equals(1.5));
expect(state.builds, equals(2));
});
testWidgets('AnimatedOpacity onEnd callback test', (WidgetTester tester) async {
await tester.pumpWidget(wrap(
child: TestAnimatedWidget(
......@@ -199,6 +315,9 @@ void main() {
),
));
final RebuildCountingState<StatefulWidget> state = tester.widget<TestAnimatedWidget>(
find.byType(TestAnimatedWidget)
).rebuildState!;
final Finder switchFinder = find.byKey(switchKey);
final FadeTransition opacityWidget = tester.widget<FadeTransition>(
find.ancestor(
......@@ -207,16 +326,23 @@ void main() {
).first,
);
expect(state.builds, equals(1));
await tester.tap(switchFinder);
expect(state.builds, equals(1));
await tester.pump();
expect(opacityWidget.opacity.value, equals(0.0));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 500));
expect(opacityWidget.opacity.value, equals(0.5));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 250));
expect(opacityWidget.opacity.value, equals(0.75));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 250));
expect(opacityWidget.opacity.value, equals(1.0));
expect(state.builds, equals(2));
});
......@@ -247,6 +373,9 @@ void main() {
),
));
final RebuildCountingState<StatefulWidget> state = tester.widget<TestAnimatedWidget>(
find.byType(TestAnimatedWidget)
).rebuildState!;
final Finder switchFinder = find.byKey(switchKey);
final SliverFadeTransition opacityWidget = tester.widget<SliverFadeTransition>(
find.ancestor(
......@@ -255,16 +384,23 @@ void main() {
).first,
);
expect(state.builds, equals(1));
await tester.tap(switchFinder);
expect(state.builds, equals(1));
await tester.pump();
expect(opacityWidget.opacity.value, equals(0.0));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 500));
expect(opacityWidget.opacity.value, equals(0.5));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 250));
expect(opacityWidget.opacity.value, equals(0.75));
expect(state.builds, equals(2));
await tester.pump(const Duration(milliseconds: 250));
expect(opacityWidget.opacity.value, equals(1.0));
expect(state.builds, equals(2));
});
testWidgets('AnimatedDefaultTextStyle onEnd callback test', (WidgetTester tester) async {
......@@ -414,6 +550,10 @@ Widget wrap({required Widget child}) {
);
}
abstract class RebuildCountingState<T extends StatefulWidget> extends State<T> {
int builds = 0;
}
class TestAnimatedWidget extends StatefulWidget {
const TestAnimatedWidget({
Key? key,
......@@ -425,11 +565,14 @@ class TestAnimatedWidget extends StatefulWidget {
final Key switchKey;
final State<StatefulWidget> state;
RebuildCountingState<StatefulWidget>? get rebuildState =>
state is RebuildCountingState<StatefulWidget> ? state as RebuildCountingState<StatefulWidget> : null;
@override
State<StatefulWidget> createState() => state; // ignore: no_logic_in_create_state, this test predates the lint
}
abstract class _TestAnimatedWidgetState extends State<TestAnimatedWidget> {
abstract class _TestAnimatedWidgetState extends RebuildCountingState<TestAnimatedWidget> {
bool toggle = false;
final Widget child = const Placeholder();
final Duration duration = animationDuration;
......@@ -444,6 +587,7 @@ abstract class _TestAnimatedWidgetState extends State<TestAnimatedWidget> {
@override
Widget build(BuildContext context) {
builds++;
final Widget animatedWidget = getAnimatedWidget();
return Stack(
......@@ -516,6 +660,30 @@ class _TestAnimatedPositionedDirectionalWidgetState extends _TestAnimatedWidgetS
}
}
class _TestAnimatedScaleWidgetState extends _TestAnimatedWidgetState {
@override
Widget getAnimatedWidget() {
return AnimatedScale(
duration: duration,
onEnd: widget.callback,
scale: toggle ? 2.0 : 1.0,
child: child,
);
}
}
class _TestAnimatedRotationWidgetState extends _TestAnimatedWidgetState {
@override
Widget getAnimatedWidget() {
return AnimatedRotation(
duration: duration,
onEnd: widget.callback,
turns: toggle ? 1.5 : 0.0,
child: child,
);
}
}
class _TestAnimatedOpacityWidgetState extends _TestAnimatedWidgetState {
@override
Widget getAnimatedWidget() {
......@@ -541,6 +709,7 @@ class _TestSliverAnimatedOpacityWidgetState extends _TestAnimatedWidgetState {
@override
Widget build(BuildContext context) {
builds++;
final Widget animatedWidget = getAnimatedWidget();
return Material(
......
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