Commit 14e728d0 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Support chaining await calls on controllers (#9389)

With this patch, you can do:

```dart
   Future<Null> 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<Null> 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.
parent 7e3bb94d
......@@ -81,7 +81,7 @@ abstract class Animation<T> extends Listenable {
@override
String toString() {
return '$runtimeType(${toStringDetails()})';
return '$runtimeType#$hashCode(${toStringDetails()})';
}
/// Provides a string describing the status of this object, but not including
......
......@@ -309,7 +309,7 @@ class RefreshIndicatorState extends State<RefreshIndicator> 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(() {
......
......@@ -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<Scaffold> 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();
}
......
......@@ -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<Null>(_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.
......
......@@ -65,7 +65,7 @@ class Ticker {
});
}
Completer<Null> _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<Null> start() {
TickerFuture start() {
assert(() {
if (isActive) {
throw new FlutterError(
......@@ -138,18 +141,22 @@ class Ticker {
return true;
});
assert(_startTime == null);
_completer = new Completer<Null>();
_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<Null> 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<Null> {
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<Null> _primaryCompleter = new Completer<Null>();
Completer<Null> _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<Null> get orCancel {
if (_secondaryCompleter == null) {
_secondaryCompleter = new Completer<Null>();
if (_completed != null) {
if (_completed) {
_secondaryCompleter.complete(null);
} else {
_secondaryCompleter.completeError(const TickerCanceled());
}
}
}
return _secondaryCompleter.future;
}
@override
Stream<Null> asStream() {
return _primaryCompleter.future.asStream();
}
@override
Future<Null> catchError(Function onError, { bool test(dynamic error) }) {
return _primaryCompleter.future.catchError(onError, test: test);
}
@override
Future<E> then<E>(dynamic f(Null value), { Function onError }) {
return _primaryCompleter.future.then<E>(f, onError: onError);
}
@override
Future<Null> timeout(Duration timeLimit, { dynamic onTimeout() }) {
return _primaryCompleter.future.timeout(timeLimit, onTimeout: onTimeout);
}
@override
Future<Null> 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
......@@ -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
......
// 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<String> log = <String>[];
Future<Null> 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, <String>['start', 'a']);
await tester.pump(); // t=0 again
expect(log, <String>['start', 'a']);
await tester.pump(const Duration(milliseconds: 50)); // t=50
expect(log, <String>['start', 'a']);
await tester.pump(const Duration(milliseconds: 100)); // t=150
expect(log, <String>['start', 'a', 'b']);
await tester.pump(const Duration(milliseconds: 50)); // t=200
expect(log, <String>['start', 'a', 'b']);
await tester.pump(const Duration(milliseconds: 400)); // t=600
expect(log, <String>['start', 'a', 'b']);
await tester.pump(const Duration(milliseconds: 199)); // t=799
expect(log, <String>['start', 'a', 'b']);
await tester.pump(const Duration(milliseconds: 51)); // t=850
expect(log, <String>['start', 'a', 'b', 'c']);
await tester.pump(const Duration(milliseconds: 400)); // t=1200
expect(log, <String>['start', 'a', 'b', 'c']);
await tester.pump(const Duration(milliseconds: 400)); // t=1600
expect(log, <String>['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<String> log = <String>[];
Future<Null> 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, <String>['start', 'a']);
await tester.pump(); // t=0 again
expect(log, <String>['start', 'a']);
await tester.pump(const Duration(milliseconds: 50)); // t=50
expect(log, <String>['start', 'a']);
await tester.pump(const Duration(milliseconds: 100)); // t=150
expect(log, <String>['start', 'a', 'b']);
await tester.pump(const Duration(milliseconds: 50)); // t=200
expect(log, <String>['start', 'a', 'b']);
await tester.pump(const Duration(milliseconds: 400)); // t=600
expect(log, <String>['start', 'a', 'b']);
await tester.pump(const Duration(milliseconds: 199)); // t=799
expect(log, <String>['start', 'a', 'b']);
await tester.pump(const Duration(milliseconds: 51)); // t=850
expect(log, <String>['start', 'a', 'b', 'c']);
await tester.pump(const Duration(milliseconds: 400)); // t=1200
expect(log, <String>['start', 'a', 'b', 'c']);
await tester.pump(const Duration(milliseconds: 400)); // t=1600
expect(log, <String>['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<String> log = <String>[];
Future<Null> 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, <String>['start']);
await tester.pump(const Duration(milliseconds: 50));
expect(log, <String>['start']);
controller1.dispose();
expect(log, <String>['start']);
await tester.idle();
expect(log, <String>['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<bool>((Null value) => true), isTrue);
expect(await f.whenComplete(() => false), isNull);
expect(await f.timeout(const Duration(seconds: 5)), isNull);
});
}
......@@ -123,7 +123,7 @@ void main() {
expect(sizes, equals(<Size>[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(<Offset>[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);
});
......
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