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); });