From 14e728d01d814e7f838350322e4b29a312c5fdf4 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Fri, 14 Apr 2017 18:27:31 -0700 Subject: [PATCH] Support chaining await calls on controllers (#9389) With this patch, you can do: ```dart Future foo() async { try { await controller.forward().orCancel; await controller.reverse().orCancel; await controller.forward().orCancel; } on TickerCanceled { // did not complete } } ``` ...in a State's async method, and so long as you dispose of the controller properly in your dispose, you'll have a nice way of doing animations in sequence without leaking the controller. try/finally works as well, if you need to allocate resources and discard them when canceled. Simultaneously, you can do: ```dart Future foo() async { await controller.forward().orCancel; await controller.reverse().orCancel; await controller.forward().orCancel; } ``` ...and have the same effect, where the method will just silently hang (and get GC'ed) if the widget is disposed, without leaking anything, if you don't need to catch the controller being killed. And all this, without spurious errors for uncaught exceptions on controllers. --- .../flutter/lib/src/animation/animation.dart | 2 +- .../src/animation/animation_controller.dart | 117 +++++++++-- .../lib/src/material/refresh_indicator.dart | 2 +- .../flutter/lib/src/material/scaffold.dart | 17 +- .../lib/src/material/tab_controller.dart | 12 +- .../flutter/lib/src/scheduler/ticker.dart | 184 +++++++++++++++--- .../lib/src/widgets/scroll_position.dart | 4 +- .../flutter/test/animation/futures_test.dart | 180 +++++++++++++++++ .../flutter/test/widgets/positioned_test.dart | 2 +- 9 files changed, 454 insertions(+), 66 deletions(-) create mode 100644 packages/flutter/test/animation/futures_test.dart diff --git a/packages/flutter/lib/src/animation/animation.dart b/packages/flutter/lib/src/animation/animation.dart index 7dc7a3999f..ee90d0eefe 100644 --- a/packages/flutter/lib/src/animation/animation.dart +++ b/packages/flutter/lib/src/animation/animation.dart @@ -81,7 +81,7 @@ abstract class Animation extends Listenable { @override String toString() { - return '$runtimeType(${toStringDetails()})'; + return '$runtimeType#$hashCode(${toStringDetails()})'; } /// Provides a string describing the status of this object, but not including diff --git a/packages/flutter/lib/src/animation/animation_controller.dart b/packages/flutter/lib/src/animation/animation_controller.dart index 1c6523b8bf..6977308052 100644 --- a/packages/flutter/lib/src/animation/animation_controller.dart +++ b/packages/flutter/lib/src/animation/animation_controller.dart @@ -13,6 +13,8 @@ import 'animation.dart'; import 'curves.dart'; import 'listener_helpers.dart'; +export 'package:flutter/scheduler.dart' show TickerFuture, TickerCanceled; + /// The direction in which an animation is running. enum _AnimationDirection { /// The animation is running from beginning to end. @@ -53,6 +55,35 @@ const Tolerance _kFlingTolerance = const Tolerance( /// in the context of tests. In other contexts, you will have to either pass a [TickerProvider] from /// a higher level (e.g. indirectly from a [State] that mixes in [TickerProviderStateMixin]), or /// create a custom [TickerProvider] subclass. +/// +/// The methods that start animations return a [TickerFuture] object which +/// completes when the animation completes successfully, and never throws an +/// error; if the animation is canceled, the future never completes. This object +/// also has a [TickerFuture.orCancel] property which returns a future that +/// completes when the animation completes successfully, and completes with an +/// error when the animation is aborted. +/// +/// This can be used to write code such as: +/// +/// ```dart +/// Future fadeOutAndUpdateState() async { +/// try { +/// await fadeAnimationController.forward().orCancel; +/// await sizeAnimationController.forward().orCancel; +/// setState(() { +/// dismissed = true; +/// }); +/// } on TickerCanceled { +/// // the animation got canceled, probably because we were disposed +/// } +/// } +/// ``` +/// +/// ...which asynchnorously runs one animation, then runs another, then changes +/// the state of the widget, without having to verify [State.mounted] is still +/// true at each step, and without having to chain futures together explicitly. +/// (This assumes that the controllers are created in [State.initState] and +/// disposed in [State.dispose].) class AnimationController extends Animation with AnimationEagerListenerMixin, AnimationLocalListenersMixin, AnimationLocalStatusListenersMixin { @@ -171,6 +202,10 @@ class AnimationController extends Animation /// /// Value listeners are notified even if this does not change the value. /// Status listeners are notified if the animation was previously playing. + /// + /// The most recently returned [TickerFuture], if any, is marked as having been + /// canceled, meaning the future never completes and its [TickerFuture.orCancel] + /// derivative future completes with a [TickerCanceled] error. set value(double newValue) { assert(newValue != null); stop(); @@ -202,7 +237,8 @@ class AnimationController extends Animation } } - /// The amount of time that has passed between the time the animation started and the most recent tick of the animation. + /// The amount of time that has passed between the time the animation started + /// and the most recent tick of the animation. /// /// If the controller is not animating, the last elapsed duration is null. Duration get lastElapsedDuration => _lastElapsedDuration; @@ -224,8 +260,12 @@ class AnimationController extends Animation /// Starts running this animation forwards (towards the end). /// - /// Returns a [Future] that completes when the animation is complete. - Future forward({ double from }) { + /// Returns a [TickerFuture] that completes when the animation is complete. + /// + /// The most recently returned [TickerFuture], if any, is marked as having been + /// canceled, meaning the future never completes and its [TickerFuture.orCancel] + /// derivative future completes with a [TickerCanceled] error. + TickerFuture forward({ double from }) { assert(() { if (duration == null) { throw new FlutterError( @@ -244,8 +284,12 @@ class AnimationController extends Animation /// Starts running this animation in reverse (towards the beginning). /// - /// Returns a [Future] that completes when the animation is complete. - Future reverse({ double from }) { + /// Returns a [TickerFuture] that completes when the animation is dismissed. + /// + /// The most recently returned [TickerFuture], if any, is marked as having been + /// canceled, meaning the future never completes and its [TickerFuture.orCancel] + /// derivative future completes with a [TickerCanceled] error. + TickerFuture reverse({ double from }) { assert(() { if (duration == null) { throw new FlutterError( @@ -264,8 +308,12 @@ class AnimationController extends Animation /// Drives the animation from its current value to target. /// - /// Returns a [Future] that completes when the animation is complete. - Future animateTo(double target, { Duration duration, Curve curve: Curves.linear }) { + /// Returns a [TickerFuture] that completes when the animation is complete. + /// + /// The most recently returned [TickerFuture], if any, is marked as having been + /// canceled, meaning the future never completes and its [TickerFuture.orCancel] + /// derivative future completes with a [TickerCanceled] error. + TickerFuture animateTo(double target, { Duration duration, Curve curve: Curves.linear }) { Duration simulationDuration = duration; if (simulationDuration == null) { assert(() { @@ -290,7 +338,7 @@ class AnimationController extends Animation AnimationStatus.completed : AnimationStatus.dismissed; _checkStatusChanged(); - return new Future.value(); + return new TickerFuture.complete(); } assert(simulationDuration > Duration.ZERO); assert(!isAnimating); @@ -301,7 +349,14 @@ class AnimationController extends Animation /// restarts the animation when it completes. /// /// Defaults to repeating between the lower and upper bounds. - Future repeat({ double min, double max, Duration period }) { + /// + /// Returns a [TickerFuture] that never completes. The [TickerFuture.onCancel] future + /// completes with an error when the animation is stopped (e.g. with [stop]). + /// + /// The most recently returned [TickerFuture], if any, is marked as having been + /// canceled, meaning the future never completes and its [TickerFuture.orCancel] + /// derivative future completes with a [TickerCanceled] error. + TickerFuture repeat({ double min, double max, Duration period }) { min ??= lowerBound; max ??= upperBound; period ??= duration; @@ -319,10 +374,18 @@ class AnimationController extends Animation return animateWith(new _RepeatingSimulation(min, max, period)); } - /// Drives the animation with a critically damped spring (within [lowerBound] and [upperBound]) and initial velocity. + /// Drives the animation with a critically damped spring (within [lowerBound] + /// and [upperBound]) and initial velocity. /// - /// If velocity is positive, the animation will complete, otherwise it will dismiss. - Future fling({ double velocity: 1.0 }) { + /// If velocity is positive, the animation will complete, otherwise it will + /// dismiss. + /// + /// Returns a [TickerFuture] that completes when the animation is complete. + /// + /// The most recently returned [TickerFuture], if any, is marked as having been + /// canceled, meaning the future never completes and its [TickerFuture.orCancel] + /// derivative future completes with a [TickerCanceled] error. + TickerFuture fling({ double velocity: 1.0 }) { _direction = velocity < 0.0 ? _AnimationDirection.reverse : _AnimationDirection.forward; final double target = velocity < 0.0 ? lowerBound - _kFlingTolerance.distance : upperBound + _kFlingTolerance.distance; @@ -332,12 +395,18 @@ class AnimationController extends Animation } /// Drives the animation according to the given simulation. - Future animateWith(Simulation simulation) { + /// + /// Returns a [TickerFuture] that completes when the animation is complete. + /// + /// The most recently returned [TickerFuture], if any, is marked as having been + /// canceled, meaning the future never completes and its [TickerFuture.orCancel] + /// derivative future completes with a [TickerCanceled] error. + TickerFuture animateWith(Simulation simulation) { stop(); return _startSimulation(simulation); } - Future _startSimulation(Simulation simulation) { + TickerFuture _startSimulation(Simulation simulation) { assert(simulation != null); assert(!isAnimating); _simulation = simulation; @@ -355,21 +424,33 @@ class AnimationController extends Animation /// /// This does not trigger any notifications. The animation stops in its /// current state. - void stop() { + /// + /// By default, the most recently returned [TickerFuture] is marked as having + /// been canceled, meaning the future never completes and its + /// [TickerFuture.orCancel] derivative future completes with a [TickerCanceled] + /// error. By passing the `completed` argument with the value false, this is + /// reversed, and the futures complete successfully. + void stop({ bool canceled: true }) { _simulation = null; _lastElapsedDuration = null; - _ticker.stop(); + _ticker.stop(canceled: canceled); } /// Release the resources used by this object. The object is no longer usable /// after this method is called. + /// + /// The most recently returned [TickerFuture], if any, is marked as having been + /// canceled, meaning the future never completes and its [TickerFuture.orCancel] + /// derivative future completes with a [TickerCanceled] error. @override void dispose() { assert(() { if (_ticker == null) { throw new FlutterError( 'AnimationController.dispose() called more than once.\n' - 'A given AnimationController cannot be disposed more than once.' + 'A given $runtimeType cannot be disposed more than once.\n' + 'The following $runtimeType object was disposed multiple times:\n' + ' $this' ); } return true; @@ -397,7 +478,7 @@ class AnimationController extends Animation _status = (_direction == _AnimationDirection.forward) ? AnimationStatus.completed : AnimationStatus.dismissed; - stop(); + stop(canceled: false); } notifyListeners(); _checkStatusChanged(); diff --git a/packages/flutter/lib/src/material/refresh_indicator.dart b/packages/flutter/lib/src/material/refresh_indicator.dart index e6a453f166..71f2478257 100644 --- a/packages/flutter/lib/src/material/refresh_indicator.dart +++ b/packages/flutter/lib/src/material/refresh_indicator.dart @@ -309,7 +309,7 @@ class RefreshIndicatorState extends State with TickerProviderS _mode = _RefreshIndicatorMode.snap; _positionController .animateTo(1.0 / _kDragSizeFactorLimit, duration: _kIndicatorSnapDuration) - .whenComplete(() { + .then((Null value) { if (mounted && _mode == _RefreshIndicatorMode.snap) { assert(widget.onRefresh != null); setState(() { diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 91d44c3bf5..8157c0427d 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -189,8 +189,8 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr @override void dispose() { - _previousController.stop(); - _currentController.stop(); + _previousController.dispose(); + _currentController.dispose(); super.dispose(); } @@ -677,6 +677,7 @@ class ScaffoldState extends State with TickerProviderStateMixin { }, onDismissed: () { if (_dismissedBottomSheets.contains(bottomSheet)) { + bottomSheet.animationController.dispose(); setState(() { _dismissedBottomSheets.remove(bottomSheet); }); @@ -944,7 +945,7 @@ class _PersistentBottomSheet extends StatefulWidget { this.builder }) : super(key: key); - final AnimationController animationController; + final AnimationController animationController; // we control it, but it must be disposed by whoever created it final VoidCallback onClosing; final VoidCallback onDismissed; final WidgetBuilder builder; @@ -954,10 +955,6 @@ class _PersistentBottomSheet extends StatefulWidget { } class _PersistentBottomSheetState extends State<_PersistentBottomSheet> { - - // We take ownership of the animation controller given in the first configuration. - // We also share control of that animation with out BottomSheet widget. - @override void initState() { super.initState(); @@ -971,12 +968,6 @@ class _PersistentBottomSheetState extends State<_PersistentBottomSheet> { assert(widget.animationController == oldWidget.animationController); } - @override - void dispose() { - widget.animationController.stop(); - super.dispose(); - } - void close() { widget.animationController.reverse(); } diff --git a/packages/flutter/lib/src/material/tab_controller.dart b/packages/flutter/lib/src/material/tab_controller.dart index 39685f078f..1a052593dc 100644 --- a/packages/flutter/lib/src/material/tab_controller.dart +++ b/packages/flutter/lib/src/material/tab_controller.dart @@ -101,10 +101,8 @@ class TabController extends ChangeNotifier { _indexIsChangingCount += 1; notifyListeners(); // Because the value of indexIsChanging may have changed. _animationController - ..animateTo(_index.toDouble(), duration: duration, curve: curve).whenComplete(() { - _indexIsChangingCount -= 1; - notifyListeners(); - }); + .animateTo(_index.toDouble(), duration: duration, curve: curve) + .orCancel.then(_indexChanged, onError: _indexChanged); } else { _indexIsChangingCount += 1; _animationController.value = _index.toDouble(); @@ -113,6 +111,12 @@ class TabController extends ChangeNotifier { } } + Null _indexChanged(dynamic value) { + _indexIsChangingCount -= 1; + notifyListeners(); + return null; + } + /// The index of the currently selected tab. Changing the index also updates /// [previousIndex], sets the [animation]'s value to index, resets /// [indexIsChanging] to false, and notifies listeners. diff --git a/packages/flutter/lib/src/scheduler/ticker.dart b/packages/flutter/lib/src/scheduler/ticker.dart index ea83031aad..48a54ce41d 100644 --- a/packages/flutter/lib/src/scheduler/ticker.dart +++ b/packages/flutter/lib/src/scheduler/ticker.dart @@ -65,7 +65,7 @@ class Ticker { }); } - Completer _completer; + TickerFuture _future; /// Whether this ticker has been silenced. /// @@ -102,7 +102,7 @@ class Ticker { /// [isTicking] will be false, but time will still be progressing. // TODO(ianh): we should teach the scheduler binding about the lifecycle events // and then this could return an accurate view of the actual scheduler. - bool get isTicking => _completer != null && !muted; + bool get isTicking => _future != null && !muted; /// Whether time is elapsing for this [Ticker]. Becomes true when [start] is /// called and false when [stop] is called. @@ -110,14 +110,17 @@ class Ticker { /// A ticker can be active yet not be actually ticking (i.e. not be calling /// the callback). To determine if a ticker is actually ticking, use /// [isTicking]. - bool get isActive => _completer != null; + bool get isActive => _future != null; Duration _startTime; /// Starts the clock for this [Ticker]. If the ticker is not [muted], then this /// also starts calling the ticker's callback once per animation frame. /// - /// The returned future resolves once the ticker [stop]s ticking. + /// The returned future resolves once the ticker [stop]s ticking. If the + /// ticker is disposed, the future does not resolve. A derivative future is + /// available from the returned [TickerFuture] object that resolves with an + /// error in that case, via [TickerFuture.orCancel]. /// /// Calling this sets [isActive] to true. /// @@ -126,7 +129,7 @@ class Ticker { /// /// By convention, this method is used by the object that receives the ticks /// (as opposed to the [TickerProvider] which created the ticker). - Future start() { + TickerFuture start() { assert(() { if (isActive) { throw new FlutterError( @@ -138,18 +141,22 @@ class Ticker { return true; }); assert(_startTime == null); - _completer = new Completer(); + _future = new TickerFuture._(); if (shouldScheduleTick) scheduleTick(); if (SchedulerBinding.instance.schedulerPhase.index > SchedulerPhase.idle.index && SchedulerBinding.instance.schedulerPhase.index < SchedulerPhase.postFrameCallbacks.index) _startTime = SchedulerBinding.instance.currentFrameTimeStamp; - return _completer.future; + return _future; } /// Stops calling this [Ticker]'s callback. /// - /// Causes the future returned by [start] to resolve. + /// If called with the `canceled` argument set to false (the default), causes + /// the future returned by [start] to resolve. If called with the `canceled` + /// argument set to true, the future does not resolve, and the future obtained + /// from [TickerFuture.orCancel], if any, resolves with a [TickerCanceled] + /// error. /// /// Calling this sets [isActive] to false. /// @@ -157,20 +164,24 @@ class Ticker { /// /// By convention, this method is used by the object that receives the ticks /// (as opposed to the [TickerProvider] which created the ticker). - void stop() { + void stop({ bool canceled: false }) { if (!isActive) return; - // We take the _completer into a local variable so that isTicking is false - // when we actually complete the future (isTicking uses _completer to + // We take the _future into a local variable so that isTicking is false + // when we actually complete the future (isTicking uses _future to // determine its state). - final Completer localCompleter = _completer; - _completer = null; + final TickerFuture localFuture = _future; + _future = null; _startTime = null; assert(!isActive); unscheduleTick(); - localCompleter.complete(); + if (canceled) { + localFuture._cancel(this); + } else { + localFuture._complete(); + } } @@ -240,19 +251,24 @@ class Ticker { /// /// This is useful if an object with a [Ticker] is given a new /// [TickerProvider] but needs to maintain continuity. In particular, this - /// maintains the identity of the [Future] returned by the [start] function of - /// the original [Ticker] if the original ticker is active. + /// maintains the identity of the [TickerFuture] returned by the [start] + /// function of the original [Ticker] if the original ticker is active. /// /// This ticker must not be active when this method is called. void absorbTicker(Ticker originalTicker) { assert(!isActive); - assert(_completer == null); + assert(_future == null); assert(_startTime == null); assert(_animationId == null); - _completer = originalTicker._completer; - _startTime = originalTicker._startTime; - if (shouldScheduleTick) - scheduleTick(); + assert((originalTicker._future == null) == (originalTicker._startTime == null), 'Cannot absorb Ticker after it has been disposed.'); + if (originalTicker._future != null) { + _future = originalTicker._future; + _startTime = originalTicker._startTime; + if (shouldScheduleTick) + scheduleTick(); + originalTicker._future = null; // so that it doesn't get disposed when we dispose of originalTicker + originalTicker.unscheduleTick(); + } originalTicker.dispose(); } @@ -260,11 +276,20 @@ class Ticker { /// after this method is called. @mustCallSuper void dispose() { - _completer = null; - // We intentionally don't null out _startTime. This means that if start() - // was ever called, the object is now in a bogus state. This weakly helps - // catch cases of use-after-dispose. - unscheduleTick(); + if (_future != null) { + final TickerFuture localFuture = _future; + _future = null; + assert(!isActive); + unscheduleTick(); + localFuture._cancel(this); + } + assert(() { + // We intentionally don't null out _startTime. This means that if start() + // was ever called, the object is now in a bogus state. This weakly helps + // catch cases of use-after-dispose. + _startTime = const Duration(); + return true; + }); } /// An optional label can be provided for debugging purposes. @@ -293,3 +318,110 @@ class Ticker { return buffer.toString(); } } + +/// An object representing an ongoing [Ticker] sequence. +/// +/// The [Ticker.start] method returns a [TickerFuture]. The [TickerFuture] will +/// complete successfully if the [Ticker] is stopped using [Ticker.stop] with +/// the `canceled` argument set to false (the default). +/// +/// If the [Ticker] is disposed without being stopped, or if it is stopped with +/// `canceled` set to true, then this Future will never complete. +/// +/// This class works like a normal [Future], but has an additional property, +/// [orCancel], which returns a derivative [Future] that completes with an error +/// if the [Ticker] that returned the [TickerFuture] was stopped with `canceled` +/// set to true, or if it was disposed without being stopped. +class TickerFuture implements Future { + TickerFuture._(); + + /// Creates a [TickerFuture] instance that represents an already-complete + /// [Ticker] sequence. + /// + /// This is useful for implementing objects that normally defer to a [Ticker] + /// but sometimes can skip the ticker because the animation is of zero + /// duration, but which still need to represent the completed animation in the + /// form of a [TickerFuture]. + TickerFuture.complete() { + _complete(); + } + + final Completer _primaryCompleter = new Completer(); + Completer _secondaryCompleter; + bool _completed; // null means unresolved, true means complete, false means canceled + + void _complete() { + assert(_completed == null); + _completed = true; + _primaryCompleter.complete(null); + _secondaryCompleter?.complete(null); + } + + void _cancel(Ticker ticker) { + assert(_completed == null); + _completed = false; + _secondaryCompleter?.completeError(new TickerCanceled(ticker)); + } + + Future get orCancel { + if (_secondaryCompleter == null) { + _secondaryCompleter = new Completer(); + if (_completed != null) { + if (_completed) { + _secondaryCompleter.complete(null); + } else { + _secondaryCompleter.completeError(const TickerCanceled()); + } + } + } + return _secondaryCompleter.future; + } + + @override + Stream asStream() { + return _primaryCompleter.future.asStream(); + } + + @override + Future catchError(Function onError, { bool test(dynamic error) }) { + return _primaryCompleter.future.catchError(onError, test: test); + } + + @override + Future then(dynamic f(Null value), { Function onError }) { + return _primaryCompleter.future.then(f, onError: onError); + } + + @override + Future timeout(Duration timeLimit, { dynamic onTimeout() }) { + return _primaryCompleter.future.timeout(timeLimit, onTimeout: onTimeout); + } + + @override + Future whenComplete(dynamic action()) { + return _primaryCompleter.future.whenComplete(action); + } + + @override + String toString() => '$runtimeType#$hashCode(${ _completed == null ? "active" : _completed ? "complete" : "canceled" })'; +} + +/// Exception thrown by [Ticker] objects on the [TickerFuture.orCancel] future +/// when the ticker is canceled. +class TickerCanceled implements Exception { + /// Creates a canceled-ticker exception. + const TickerCanceled([this.ticker]); + + /// Reference to the [Ticker] object that was canceled. + /// + /// This may be null in the case that the [Future] created for + /// [TickerFuture.orCancel] was created after the future was canceled. + final Ticker ticker; + + @override + String toString() { + if (ticker != null) + return 'This ticker was canceled: $ticker'; + return 'The ticker was canceled before the "orCancel" property was first used.'; + } +} \ No newline at end of file diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index 45d340acf4..1352b70e08 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -694,7 +694,7 @@ class BallisticScrollActivity extends ScrollActivity { ) ..addListener(_tick) ..animateWith(simulation) - .whenComplete(_end); + .whenComplete(_end); // won't trigger if we dispose _controller first } @override @@ -773,7 +773,7 @@ class DrivenScrollActivity extends ScrollActivity { ) ..addListener(_tick) ..animateTo(to, duration: duration, curve: curve) - .whenComplete(_end); + .whenComplete(_end); // won't trigger if we dispose _controller first } @override diff --git a/packages/flutter/test/animation/futures_test.dart b/packages/flutter/test/animation/futures_test.dart new file mode 100644 index 0000000000..f7ee4b68ef --- /dev/null +++ b/packages/flutter/test/animation/futures_test.dart @@ -0,0 +1,180 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/animation.dart'; +import 'package:flutter/widgets.dart'; + +void main() { + testWidgets('awaiting animation controllers - using direct future', (WidgetTester tester) async { + final AnimationController controller1 = new AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + final AnimationController controller2 = new AnimationController( + duration: const Duration(milliseconds: 600), + vsync: const TestVSync(), + ); + final AnimationController controller3 = new AnimationController( + duration: const Duration(milliseconds: 300), + vsync: const TestVSync(), + ); + final List log = []; + Future runTest() async { + log.add('a'); // t=0 + await controller1.forward(); // starts at t=0 again + log.add('b'); // wants to end at t=100 but missed frames until t=150 + await controller2.forward(); // starts at t=200 + log.add('c'); // wants to end at t=800 but missed frames until t=850 + await controller3.forward(); // starts at t=1200 + log.add('d'); // wants to end at t=1500 but missed frames until t=1600 + } + log.add('start'); + runTest().then((Null value) { + log.add('end'); + }); + await tester.pump(); // t=0 + expect(log, ['start', 'a']); + await tester.pump(); // t=0 again + expect(log, ['start', 'a']); + await tester.pump(const Duration(milliseconds: 50)); // t=50 + expect(log, ['start', 'a']); + await tester.pump(const Duration(milliseconds: 100)); // t=150 + expect(log, ['start', 'a', 'b']); + await tester.pump(const Duration(milliseconds: 50)); // t=200 + expect(log, ['start', 'a', 'b']); + await tester.pump(const Duration(milliseconds: 400)); // t=600 + expect(log, ['start', 'a', 'b']); + await tester.pump(const Duration(milliseconds: 199)); // t=799 + expect(log, ['start', 'a', 'b']); + await tester.pump(const Duration(milliseconds: 51)); // t=850 + expect(log, ['start', 'a', 'b', 'c']); + await tester.pump(const Duration(milliseconds: 400)); // t=1200 + expect(log, ['start', 'a', 'b', 'c']); + await tester.pump(const Duration(milliseconds: 400)); // t=1600 + expect(log, ['start', 'a', 'b', 'c', 'd', 'end']); + }); + + testWidgets('awaiting animation controllers - using orCancel', (WidgetTester tester) async { + final AnimationController controller1 = new AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + final AnimationController controller2 = new AnimationController( + duration: const Duration(milliseconds: 600), + vsync: const TestVSync(), + ); + final AnimationController controller3 = new AnimationController( + duration: const Duration(milliseconds: 300), + vsync: const TestVSync(), + ); + final List log = []; + Future runTest() async { + log.add('a'); // t=0 + await controller1.forward().orCancel; // starts at t=0 again + log.add('b'); // wants to end at t=100 but missed frames until t=150 + await controller2.forward().orCancel; // starts at t=200 + log.add('c'); // wants to end at t=800 but missed frames until t=850 + await controller3.forward().orCancel; // starts at t=1200 + log.add('d'); // wants to end at t=1500 but missed frames until t=1600 + } + log.add('start'); + runTest().then((Null value) { + log.add('end'); + }); + await tester.pump(); // t=0 + expect(log, ['start', 'a']); + await tester.pump(); // t=0 again + expect(log, ['start', 'a']); + await tester.pump(const Duration(milliseconds: 50)); // t=50 + expect(log, ['start', 'a']); + await tester.pump(const Duration(milliseconds: 100)); // t=150 + expect(log, ['start', 'a', 'b']); + await tester.pump(const Duration(milliseconds: 50)); // t=200 + expect(log, ['start', 'a', 'b']); + await tester.pump(const Duration(milliseconds: 400)); // t=600 + expect(log, ['start', 'a', 'b']); + await tester.pump(const Duration(milliseconds: 199)); // t=799 + expect(log, ['start', 'a', 'b']); + await tester.pump(const Duration(milliseconds: 51)); // t=850 + expect(log, ['start', 'a', 'b', 'c']); + await tester.pump(const Duration(milliseconds: 400)); // t=1200 + expect(log, ['start', 'a', 'b', 'c']); + await tester.pump(const Duration(milliseconds: 400)); // t=1600 + expect(log, ['start', 'a', 'b', 'c', 'd', 'end']); + }); + + testWidgets('awaiting animation controllers and failing', (WidgetTester tester) async { + final AnimationController controller1 = new AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + final List log = []; + Future runTest() async { + try { + log.add('start'); + await controller1.forward().orCancel; + log.add('fail'); + } on TickerCanceled { + log.add('caught'); + } + } + runTest().then((Null value) { + log.add('end'); + }); + await tester.pump(); // start ticker + expect(log, ['start']); + await tester.pump(const Duration(milliseconds: 50)); + expect(log, ['start']); + controller1.dispose(); + expect(log, ['start']); + await tester.idle(); + expect(log, ['start', 'caught', 'end']); + }); + + testWidgets('creating orCancel future later', (WidgetTester tester) async { + final AnimationController controller1 = new AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + final TickerFuture f = controller1.forward(); + await tester.pump(); // start ticker + await tester.pump(const Duration(milliseconds: 200)); // end ticker + await f; // should be a no-op + await f.orCancel; // should create a resolved future + expect(true, isTrue); // should reach here + }); + + testWidgets('creating orCancel future later', (WidgetTester tester) async { + final AnimationController controller1 = new AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + final TickerFuture f = controller1.forward(); + await tester.pump(); // start ticker + controller1.stop(); // cancel ticker + bool ok = false; + try { + await f.orCancel; // should create a resolved future + } on TickerCanceled { + ok = true; + } + expect(ok, isTrue); // should reach here + }); + + testWidgets('TickerFuture is a Future', (WidgetTester tester) async { + final AnimationController controller1 = new AnimationController( + duration: const Duration(milliseconds: 100), + vsync: const TestVSync(), + ); + final TickerFuture f = controller1.forward(); + await tester.pump(); // start ticker + await tester.pump(const Duration(milliseconds: 200)); // end ticker + expect(await f.asStream().single, isNull); + await f.catchError((dynamic e) { throw 'do not reach'; }); + expect(await f.then((Null value) => true), isTrue); + expect(await f.whenComplete(() => false), isNull); + expect(await f.timeout(const Duration(seconds: 5)), isNull); + }); +} diff --git a/packages/flutter/test/widgets/positioned_test.dart b/packages/flutter/test/widgets/positioned_test.dart index 39706798f8..7dc912258e 100644 --- a/packages/flutter/test/widgets/positioned_test.dart +++ b/packages/flutter/test/widgets/positioned_test.dart @@ -123,7 +123,7 @@ void main() { expect(sizes, equals([const Size(10.0, 10.0), const Size(10.0, 10.0), const Size(10.0, 10.0), const Size(10.0, 10.0), const Size(10.0, 10.0), const Size(10.0, 10.0)])); expect(positions, equals([const Offset(10.0, 10.0), const Offset(10.0, 10.0), const Offset(17.0, 17.0), const Offset(24.0, 24.0), const Offset(45.0, 45.0), const Offset(80.0, 80.0)])); - controller.stop(); + controller.stop(canceled: false); await tester.pump(); expect(completer.isCompleted, isTrue); });