From 669e13ebd471b18feaf4f00c3bdbfc43143e6619 Mon Sep 17 00:00:00 2001 From: Yegor Date: Wed, 19 Jul 2017 15:53:28 -0700 Subject: [PATCH] AnimatedSize: state machine, tests, animate only when needed (#11305) --- .../lib/src/rendering/animated_size.dart | 156 +++++-- .../test/widgets/animated_size_test.dart | 381 +++++++++++------- 2 files changed, 354 insertions(+), 183 deletions(-) diff --git a/packages/flutter/lib/src/rendering/animated_size.dart b/packages/flutter/lib/src/rendering/animated_size.dart index 788404dddc..28f34a2231 100644 --- a/packages/flutter/lib/src/rendering/animated_size.dart +++ b/packages/flutter/lib/src/rendering/animated_size.dart @@ -10,6 +10,41 @@ import 'box.dart'; import 'object.dart'; import 'shifted_box.dart'; +/// A [RenderAnimatedSize] can be in exactly one of these states. +@visibleForTesting +enum RenderAnimatedSizeState { + /// The initial state, when we do not yet know what the starting and target + /// sizes are to animate. + /// + /// Next possible state is [stable]. + start, + + /// At this state the child's size is assumed to be stable and we are either + /// animating, or waiting for the child's size to change. + /// + /// Next possible state is [changed]. + stable, + + /// At this state we know that the child has changed once after being assumed + /// [stable]. + /// + /// Next possible states are: + /// + /// - [stable] - if the child's size stabilized immediately, this is a signal + /// for us to begin animating the size towards the child's new size. + /// - [unstable] - if the child's size continues to change, we assume it is + /// not stable and enter the [unstable] state. + changed, + + /// At this state the child's size is assumed to be unstable. + /// + /// Instead of chasing the child's size in this state we tightly track the + /// child's size until it stabilizes. + /// + /// Next possible state is [stable]. + unstable, +} + /// A render object that animates its size to its child's size over a given /// [duration] and with a given [curve]. If the child's size itself animates /// (i.e. if it changes size two frames in a row, as opposed to abruptly @@ -60,10 +95,16 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { AnimationController _controller; CurvedAnimation _animation; final SizeTween _sizeTween = new SizeTween(); - bool _didChangeTargetSizeLastFrame = false; bool _hasVisualOverflow; double _lastValue; + /// The state this size animation is in. + /// + /// See [RenderAnimatedSizeState] for possible states. + @visibleForTesting + RenderAnimatedSizeState get state => _state; + RenderAnimatedSizeState _state = RenderAnimatedSizeState.start; + /// The duration of the animation. Duration get duration => _controller.duration; set duration(Duration value) { @@ -82,6 +123,12 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { _animation.curve = value; } + /// Whether the size is being currently animated towards the child's size. + /// + /// See [RenderAnimatedSizeState] for situations when we may not be animating + /// the size. + bool get isAnimating => _controller.isAnimating; + /// The [TickerProvider] for the [AnimationController] that runs the animation. TickerProvider get vsync => _vsync; TickerProvider _vsync; @@ -93,16 +140,10 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { _controller.resync(vsync); } - @override - void attach(PipelineOwner owner) { - super.attach(owner); - if (_animatedSize != _sizeTween.end && !_controller.isAnimating) - _controller.forward(); - } - @override void detach() { _controller.stop(); + _state = RenderAnimatedSizeState.start; super.detach(); } @@ -121,29 +162,25 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { } child.layout(constraints, parentUsesSize: true); - if (_sizeTween.end != child.size) { - _sizeTween.begin = _animatedSize ?? child.size; - _sizeTween.end = child.size; - if (_didChangeTargetSizeLastFrame) { - size = child.size; - _controller.stop(); - } else { - // Don't register first change as a last-frame change. - if (_sizeTween.end != _sizeTween.begin) - _didChangeTargetSizeLastFrame = true; - - _lastValue = 0.0; - _controller.forward(from: 0.0); - - size = constraints.constrain(_animatedSize); - } - } else { - _didChangeTargetSizeLastFrame = false; - - size = constraints.constrain(_animatedSize); + switch(_state) { + case RenderAnimatedSizeState.start: + _layoutStart(); + break; + case RenderAnimatedSizeState.stable: + _layoutStable(); + break; + case RenderAnimatedSizeState.changed: + _layoutChanged(); + break; + case RenderAnimatedSizeState.unstable: + _layoutUnstable(); + break; + default: + throw new StateError('$runtimeType is in an invalid state $_state'); } + size = constraints.constrain(_animatedSize); alignChild(); if (size.width < _sizeTween.end.width || @@ -151,6 +188,69 @@ class RenderAnimatedSize extends RenderAligningShiftedBox { _hasVisualOverflow = true; } + void _restartAnimation() { + _lastValue = 0.0; + _controller.forward(from: 0.0); + } + + /// Laying out the child for the first time. + /// + /// We have the initial size to animate from, but we do not have the target + /// size to animate to, so we set both ends to child's size. + void _layoutStart() { + _sizeTween.begin = _sizeTween.end = child.size; + _state = RenderAnimatedSizeState.stable; + } + + /// At this state we're assuming the child size is stable and letting the + /// animation run its course. + /// + /// If during animation the size of the child changes we restart the + /// animation. + void _layoutStable() { + if (_sizeTween.end != child.size) { + _sizeTween.end = child.size; + _restartAnimation(); + _state = RenderAnimatedSizeState.changed; + } else if (_controller.value == _controller.upperBound) { + // Animation finished. Reset target sizes. + _sizeTween.begin = _sizeTween.end = child.size; + } + } + + /// This state indicates that the size of the child changed once after being + /// considered stable. + /// + /// If the child stabilizes immediately, we go back to stable state. If it + /// changes again, we match the child's size, restart animation and go to + /// unstable state. + void _layoutChanged() { + if (_sizeTween.end != child.size) { + // Child size changed again. Match the child's size and restart animation. + _sizeTween.begin = _sizeTween.end = child.size; + _restartAnimation(); + _state = RenderAnimatedSizeState.unstable; + } else { + // Child size stabilized. + _state = RenderAnimatedSizeState.stable; + } + } + + /// The child's size is not stable. + /// + /// Continue tracking the child's size until is stabilizes. + void _layoutUnstable() { + if (_sizeTween.end != child.size) { + // Still unstable. Continue tracking the child. + _sizeTween.begin = _sizeTween.end = child.size; + _restartAnimation(); + } else { + // Child size stabilized. + _controller.stop(); + _state = RenderAnimatedSizeState.stable; + } + } + @override void paint(PaintingContext context, Offset offset) { if (child != null && _hasVisualOverflow) { diff --git a/packages/flutter/test/widgets/animated_size_test.dart b/packages/flutter/test/widgets/animated_size_test.dart index 2c0b8609f6..fd669a2a3c 100644 --- a/packages/flutter/test/widgets/animated_size_test.dart +++ b/packages/flutter/test/widgets/animated_size_test.dart @@ -16,85 +16,10 @@ class TestPaintingContext implements PaintingContext { } void main() { - testWidgets('AnimatedSize test', (WidgetTester tester) async { - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: const SizedBox( - width: 100.0, - height: 100.0, - ), - ), - ), - ); - - RenderBox box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); - - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: const SizedBox( - width: 200.0, - height: 200.0, - ), - ), - ), - ); - - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(150.0)); - expect(box.size.height, equals(150.0)); - - TestPaintingContext context = new TestPaintingContext(); - box.paint(context, Offset.zero); - expect(context.invocations.first.memberName, equals(#pushClipRect)); - - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(200.0)); - expect(box.size.height, equals(200.0)); - - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: const SizedBox( - width: 100.0, - height: 100.0, - ), - ), - ), - ); - - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(150.0)); - expect(box.size.height, equals(150.0)); - - context = new TestPaintingContext(); - box.paint(context, Offset.zero); - expect(context.invocations.first.memberName, equals(#paintChild)); - - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); - }); - - testWidgets('AnimatedSize constrained test', (WidgetTester tester) async { - await tester.pumpWidget( - new Center( - child: new SizedBox ( - width: 100.0, - height: 100.0, + group('AnimatedSize', () { + testWidgets('animates forwards then backwards with stable-sized children', (WidgetTester tester) async { + await tester.pumpWidget( + new Center( child: new AnimatedSize( duration: const Duration(milliseconds: 200), vsync: tester, @@ -104,18 +29,14 @@ void main() { ), ), ), - ), - ); + ); - RenderBox box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); + RenderBox box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); - await tester.pumpWidget( - new Center( - child: new SizedBox ( - width: 100.0, - height: 100.0, + await tester.pumpWidget( + new Center( child: new AnimatedSize( duration: const Duration(milliseconds: 200), vsync: tester, @@ -125,88 +46,238 @@ void main() { ), ), ), - ), - ); + ); - await tester.pump(const Duration(milliseconds: 100)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); - }); + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(150.0)); + expect(box.size.height, equals(150.0)); - testWidgets('AnimatedSize with AnimatedContainer', (WidgetTester tester) async { - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: new AnimatedContainer( - duration: const Duration(milliseconds: 100), + TestPaintingContext context = new TestPaintingContext(); + box.paint(context, Offset.zero); + expect(context.invocations.first.memberName, equals(#pushClipRect)); + + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(200.0)); + expect(box.size.height, equals(200.0)); + + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 100.0, + height: 100.0, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(150.0)); + expect(box.size.height, equals(150.0)); + + context = new TestPaintingContext(); + box.paint(context, Offset.zero); + expect(context.invocations.first.memberName, equals(#paintChild)); + + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + }); + + testWidgets('clamps animated size to constraints', (WidgetTester tester) async { + await tester.pumpWidget( + new Center( + child: new SizedBox ( width: 100.0, height: 100.0, + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 100.0, + height: 100.0, + ), + ), ), ), - ), - ); + ); - RenderBox box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(100.0)); - expect(box.size.height, equals(100.0)); + RenderBox box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: new AnimatedContainer( - duration: const Duration(milliseconds: 100), - width: 200.0, - height: 200.0, - ), - ), - ), - ); - - await tester.pump(const Duration(milliseconds: 1)); // register change - await tester.pump(const Duration(milliseconds: 49)); - expect(box.size.width, equals(150.0)); - expect(box.size.height, equals(150.0)); - await tester.pump(const Duration(milliseconds: 50)); - box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(200.0)); - expect(box.size.height, equals(200.0)); - }); - - testWidgets('AnimatedSize resync', (WidgetTester tester) async { - await tester.pumpWidget( - const Center( - child: const AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: const TestVSync(), - child: const SizedBox( + // Attempt to animate beyond the outer SizedBox. + await tester.pumpWidget( + new Center( + child: new SizedBox ( width: 100.0, height: 100.0, + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 200.0, + height: 200.0, + ), + ), ), ), - ), - ); + ); - await tester.pumpWidget( - new Center( - child: new AnimatedSize( - duration: const Duration(milliseconds: 200), - vsync: tester, - child: const SizedBox( - width: 200.0, - height: 100.0, + // Verify that animated size is the same as the outer SizedBox. + await tester.pump(const Duration(milliseconds: 100)); + box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(100.0)); + expect(box.size.height, equals(100.0)); + }); + + testWidgets('tracks unstable child, then resumes animation when child stabilizes', (WidgetTester tester) async { + Future pumpMillis(int millis) async { + await tester.pump(new Duration(milliseconds: millis)); + } + + void verify({double size, RenderAnimatedSizeState state}) { + assert(size != null || state != null); + final RenderAnimatedSize box = tester.renderObject(find.byType(AnimatedSize)); + if (size != null) { + expect(box.size.width, size); + expect(box.size.height, size); + } + if (state != null) { + expect(box.state, state); + } + } + + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: new AnimatedContainer( + duration: const Duration(milliseconds: 100), + width: 100.0, + height: 100.0, + ), ), ), - ), - ); + ); - await tester.pump(const Duration(milliseconds: 100)); + verify(size: 100.0, state: RenderAnimatedSizeState.stable); - final RenderBox box = tester.renderObject(find.byType(AnimatedSize)); - expect(box.size.width, equals(150.0)); + // Animate child size from 100 to 200 slowly (100ms). + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: new AnimatedContainer( + duration: const Duration(milliseconds: 100), + width: 200.0, + height: 200.0, + ), + ), + ), + ); + + // Make sure animation proceeds at child's pace, with AnimatedSize + // tightly tracking the child's size. + verify(state: RenderAnimatedSizeState.stable); + await pumpMillis(1); // register change + verify(state: RenderAnimatedSizeState.changed); + await pumpMillis(49); + verify(size: 150.0, state: RenderAnimatedSizeState.unstable); + await pumpMillis(50); + verify(size: 200.0, state: RenderAnimatedSizeState.unstable); + + // Stabilize size + await pumpMillis(50); + verify(size: 200.0, state: RenderAnimatedSizeState.stable); + + // Quickly (in 1ms) change size back to 100 + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: new AnimatedContainer( + duration: const Duration(milliseconds: 1), + width: 100.0, + height: 100.0, + ), + ), + ), + ); + + verify(size: 200.0, state: RenderAnimatedSizeState.stable); + await pumpMillis(1); // register change + verify(state: RenderAnimatedSizeState.changed); + await pumpMillis(100); + verify(size: 150.0, state: RenderAnimatedSizeState.stable); + await pumpMillis(100); + verify(size: 100.0, state: RenderAnimatedSizeState.stable); + }); + + testWidgets('resyncs its animation controller', (WidgetTester tester) async { + await tester.pumpWidget( + const Center( + child: const AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: const TestVSync(), + child: const SizedBox( + width: 100.0, + height: 100.0, + ), + ), + ), + ); + + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 200.0, + height: 100.0, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 100)); + + final RenderBox box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, equals(150.0)); + }); + + testWidgets('does not run animation unnecessarily', (WidgetTester tester) async { + await tester.pumpWidget( + new Center( + child: new AnimatedSize( + duration: const Duration(milliseconds: 200), + vsync: tester, + child: const SizedBox( + width: 100.0, + height: 100.0, + ), + ), + ), + ); + + for (int i = 0; i < 20; i++) { + final RenderAnimatedSize box = tester.renderObject(find.byType(AnimatedSize)); + expect(box.size.width, 100.0); + expect(box.size.height, 100.0); + expect(box.state, RenderAnimatedSizeState.stable); + expect(box.isAnimating, false); + await tester.pump(const Duration(milliseconds: 10)); + } + }); }); }