From bc5307f5af5a0f116696cab01d30865d9e539999 Mon Sep 17 00:00:00 2001 From: Hixie Date: Wed, 2 Dec 2015 10:00:26 -0800 Subject: [PATCH] buildForwardTransition() For those times when you want to do something as you move away from a route into the next one, as well as when you move into it from the previous one. --- examples/stocks/lib/main.dart | 1 + examples/stocks/lib/stock_home.dart | 11 +- examples/stocks/lib/stock_symbol_viewer.dart | 11 +- .../flutter/lib/src/animation/curves.dart | 1 + .../lib/src/animation/performance.dart | 217 +++++++++++++++++- packages/flutter/lib/src/material/page.dart | 4 +- packages/flutter/lib/src/widgets/routes.dart | 53 ++++- .../flutter/lib/src/widgets/transitions.dart | 5 + .../widget/page_forward_transitions_test.dart | 165 +++++++++++++ packages/unit/test/widget/snack_bar_test.dart | 6 +- packages/unit/test/widget/test_widgets.dart | 7 + 11 files changed, 465 insertions(+), 16 deletions(-) create mode 100644 packages/unit/test/widget/page_forward_transitions_test.dart diff --git a/examples/stocks/lib/main.dart b/examples/stocks/lib/main.dart index ef8d361b0d..ac8aa811f8 100644 --- a/examples/stocks/lib/main.dart +++ b/examples/stocks/lib/main.dart @@ -8,6 +8,7 @@ import 'dart:async'; import 'dart:math' as math; import 'dart:ui' as ui; +import 'package:flutter/animation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/painting.dart'; diff --git a/examples/stocks/lib/stock_home.dart b/examples/stocks/lib/stock_home.dart index e10794e8d8..d6b2e0216d 100644 --- a/examples/stocks/lib/stock_home.dart +++ b/examples/stocks/lib/stock_home.dart @@ -146,13 +146,22 @@ class StockHomeState extends State { } Widget buildToolBar() { + PageRoute page = ModalRoute.of(context); return new ToolBar( elevation: 0, left: new IconButton( icon: "navigation/menu", onPressed: _showDrawer ), - center: new Text('Stocks'), + center: new FadeTransition( + opacity: new AnimatedValue( + 1.0, + end: 0.0, + curve: const Interval(0.0, 0.5) + ), + performance: page.forwardPerformance, + child: new Text('Stocks') + ), right: [ new IconButton( icon: "action/search", diff --git a/examples/stocks/lib/stock_symbol_viewer.dart b/examples/stocks/lib/stock_symbol_viewer.dart index fc0173321f..6e84b6fdd5 100644 --- a/examples/stocks/lib/stock_symbol_viewer.dart +++ b/examples/stocks/lib/stock_symbol_viewer.dart @@ -53,6 +53,7 @@ class StockSymbolPage extends StatelessComponent { final Stock stock; Widget build(BuildContext context) { + PageRoute page = ModalRoute.of(context); return new Scaffold( toolBar: new ToolBar( left: new IconButton( @@ -61,7 +62,15 @@ class StockSymbolPage extends StatelessComponent { Navigator.pop(context); } ), - center: new Text(stock.name) + center: new FadeTransition( + opacity: new AnimatedValue( + 0.0, + end: 1.0, + curve: const Interval(0.5, 1.0) + ), + performance: page.performance, + child: new Text(stock.name) + ) ), body: new Block([ new Container( diff --git a/packages/flutter/lib/src/animation/curves.dart b/packages/flutter/lib/src/animation/curves.dart index 3d0f6f5702..ddbd71c3ab 100644 --- a/packages/flutter/lib/src/animation/curves.dart +++ b/packages/flutter/lib/src/animation/curves.dart @@ -45,6 +45,7 @@ class Interval implements Curve { assert(start <= 1.0); assert(end >= 0.0); assert(end <= 1.0); + assert(end >= start); t = ((t - start) / (end - start)).clamp(0.0, 1.0); if (t == 0.0 || t == 1.0) return t; diff --git a/packages/flutter/lib/src/animation/performance.dart b/packages/flutter/lib/src/animation/performance.dart index 49e4f57806..16f9f17af7 100644 --- a/packages/flutter/lib/src/animation/performance.dart +++ b/packages/flutter/lib/src/animation/performance.dart @@ -109,12 +109,10 @@ class ReversePerformance extends PerformanceView { final List _statusListeners = new List(); - /// Calls listener every time the status of this performance changes void addStatusListener(PerformanceStatusListener listener) { _statusListeners.add(listener); } - /// Stops calling the listener every time the status of this performance changes void removeStatusListener(PerformanceStatusListener listener) { _statusListeners.remove(listener); } @@ -148,6 +146,217 @@ class ReversePerformance extends PerformanceView { } } +enum _TrainHoppingMode { minimize, maximize } + +/// This performance starts by proxying one performance, but can be given a +/// second performance. When their times cross (either because the second is +/// going in the opposite direction, or because the one overtakes the other), +/// the performance hops over to proxying the second performance, and the second +/// performance becomes the new "first" performance. +class TrainHoppingPerformance extends PerformanceView { + TrainHoppingPerformance(this._currentTrain, this._nextTrain, { this.onSwitchedTrain }) { + assert(_currentTrain != null); + if (_nextTrain != null) { + + if (_currentTrain.progress > _nextTrain.progress) { + _mode = _TrainHoppingMode.maximize; + } else { + _mode = _TrainHoppingMode.minimize; + if (_currentTrain.progress == _nextTrain.progress) { + _currentTrain = _nextTrain; + _nextTrain = null; + } + } + } + _currentTrain.addStatusListener(_statusChangeHandler); + _currentTrain.addListener(_valueChangeHandler); + if (_nextTrain != null) + _nextTrain.addListener(_valueChangeHandler); + assert(_mode != null); + } + + PerformanceView get currentTrain => _currentTrain; + PerformanceView _currentTrain; + PerformanceView _nextTrain; + _TrainHoppingMode _mode; + + VoidCallback onSwitchedTrain; + + void updateVariable(Animatable variable) { + assert(_currentTrain != null); + variable.setProgress(progress, curveDirection); + } + + final List _listeners = new List(); + + void addListener(VoidCallback listener) { + assert(_currentTrain != null); + _listeners.add(listener); + } + + void removeListener(VoidCallback listener) { + assert(_currentTrain != null); + _listeners.remove(listener); + } + + final List _statusListeners = new List(); + + void addStatusListener(PerformanceStatusListener listener) { + assert(_currentTrain != null); + _statusListeners.add(listener); + } + + void removeStatusListener(PerformanceStatusListener listener) { + assert(_currentTrain != null); + _statusListeners.remove(listener); + } + + PerformanceStatus _lastStatus; + void _statusChangeHandler(PerformanceStatus status) { + assert(_currentTrain != null); + if (status != _lastStatus) { + List localListeners = new List.from(_statusListeners); + for (PerformanceStatusListener listener in localListeners) + listener(status); + _lastStatus = status; + } + assert(_lastStatus != null); + } + + PerformanceStatus get status => _currentTrain.status; + AnimationDirection get direction => _currentTrain.direction; + AnimationDirection get curveDirection => _currentTrain.curveDirection; + + double _lastProgress; + void _valueChangeHandler() { + assert(_currentTrain != null); + bool hop = false; + if (_nextTrain != null) { + switch (_mode) { + case _TrainHoppingMode.minimize: + hop = _nextTrain.progress <= _currentTrain.progress; + break; + case _TrainHoppingMode.maximize: + hop = _nextTrain.progress >= _currentTrain.progress; + break; + } + if (hop) { + _currentTrain.removeStatusListener(_statusChangeHandler); + _currentTrain.removeListener(_valueChangeHandler); + _currentTrain = _nextTrain; + _nextTrain.addListener(_valueChangeHandler); + _statusChangeHandler(_nextTrain.status); + } + } + double newProgress = progress; + if (newProgress != _lastProgress) { + List localListeners = new List.from(_listeners); + for (VoidCallback listener in localListeners) + listener(); + _lastProgress = newProgress; + } + assert(_lastProgress != null); + if (hop && onSwitchedTrain != null) + onSwitchedTrain(); + } + + double get progress => _currentTrain.progress; + + /// Frees all the resources used by this performance. + /// After this is called, this object is no longer usable. + void dispose() { + assert(_currentTrain != null); + _currentTrain.removeStatusListener(_statusChangeHandler); + _currentTrain.removeListener(_valueChangeHandler); + _currentTrain = null; + if (_nextTrain != null) { + _nextTrain.removeListener(_valueChangeHandler); + _nextTrain = null; + } + } +} + +class ProxyPerformance extends PerformanceView { + ProxyPerformance([PerformanceView performance]) { + masterPerformance = performance; + } + + PerformanceView get masterPerformance => _masterPerformance; + PerformanceView _masterPerformance; + void set masterPerformance(PerformanceView value) { + if (value == _masterPerformance) + return; + if (_masterPerformance != null) { + _masterPerformance.removeStatusListener(_statusChangeHandler); + _masterPerformance.removeListener(_valueChangeHandler); + } + _masterPerformance = value; + if (_masterPerformance != null) { + _masterPerformance.addListener(_valueChangeHandler); + _masterPerformance.addStatusListener(_statusChangeHandler); + _valueChangeHandler(); + _statusChangeHandler(_masterPerformance.status); + } + } + + void updateVariable(Animatable variable) { + variable.setProgress(progress, curveDirection); + } + + final List _listeners = new List(); + + void addListener(VoidCallback listener) { + _listeners.add(listener); + } + + void removeListener(VoidCallback listener) { + _listeners.remove(listener); + } + + final List _statusListeners = new List(); + + void addStatusListener(PerformanceStatusListener listener) { + _statusListeners.add(listener); + } + + void removeStatusListener(PerformanceStatusListener listener) { + _statusListeners.remove(listener); + } + + PerformanceStatus _status = PerformanceStatus.dismissed; + AnimationDirection _direction = AnimationDirection.forward; + AnimationDirection _curveDirection = AnimationDirection.forward; + void _statusChangeHandler(PerformanceStatus status) { + assert(_masterPerformance != null); + if (status != _status) { + _status = status; + _direction = _masterPerformance.direction; + List localListeners = new List.from(_statusListeners); + for (PerformanceStatusListener listener in localListeners) + listener(status); + } + } + + PerformanceStatus get status => _status; + AnimationDirection get direction => _direction; + AnimationDirection get curveDirection => _curveDirection; + + double _progress = 0.0; + void _valueChangeHandler() { + assert(_masterPerformance != null); + double newProgress = _masterPerformance.progress; + if (newProgress != _progress) { + _progress = newProgress; + _curveDirection = _masterPerformance.curveDirection; + List localListeners = new List.from(_listeners); + for (VoidCallback listener in localListeners) + listener(); + } + } + + double get progress => _progress; +} + class _RepeatingSimulation extends Simulation { _RepeatingSimulation(this.min, this.max, Duration period) : _periodInSeconds = period.inMicroseconds.toDouble() / Duration.MICROSECONDS_PER_SECOND { @@ -280,12 +489,10 @@ class Performance extends PerformanceView { final List _listeners = new List(); - /// Calls the listener every time the progress of this performance changes void addListener(VoidCallback listener) { _listeners.add(listener); } - /// Stop calling the listener every time the progress of this performance changes void removeListener(VoidCallback listener) { _listeners.remove(listener); } @@ -298,12 +505,10 @@ class Performance extends PerformanceView { final List _statusListeners = new List(); - /// Calls listener every time the status of this performance changes void addStatusListener(PerformanceStatusListener listener) { _statusListeners.add(listener); } - /// Stops calling the listener every time the status of this performance changes void removeStatusListener(PerformanceStatusListener listener) { _statusListeners.remove(listener); } diff --git a/packages/flutter/lib/src/material/page.dart b/packages/flutter/lib/src/material/page.dart index 6e504b903d..cd2c4694ac 100644 --- a/packages/flutter/lib/src/material/page.dart +++ b/packages/flutter/lib/src/material/page.dart @@ -38,6 +38,8 @@ class _MaterialPageTransition extends TransitionWithChild { } } +const Duration kMaterialPageRouteTransitionDuration = const Duration(milliseconds: 150); + class MaterialPageRoute extends PageRoute { MaterialPageRoute({ this.builder, @@ -49,7 +51,7 @@ class MaterialPageRoute extends PageRoute { final WidgetBuilder builder; - Duration get transitionDuration => const Duration(milliseconds: 150); + Duration get transitionDuration => kMaterialPageRouteTransitionDuration; bool get barrierDismissable => false; Color get barrierColor => Colors.black54; diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index ae37b5a902..4ac431d197 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -72,6 +72,9 @@ abstract class TransitionRoute extends OverlayRoute { /// popped. Future get popped => _popCompleter?.future; final Completer _popCompleter; + + /// This future completes only once the transition itself has finished, after + /// the overlay entries have been removed from the navigator's overlay. Future get completed => _transitionCompleter?.future; final Completer _transitionCompleter; @@ -143,6 +146,52 @@ abstract class TransitionRoute extends OverlayRoute { super.dispose(); } + + final ProxyPerformance forwardPerformance = new ProxyPerformance(); + + void didPushNext(Route nextRoute) { + if (nextRoute is TransitionRoute) { + PerformanceView current = forwardPerformance.masterPerformance; + if (current != null) { + if (current is TrainHoppingPerformance) { + TrainHoppingPerformance newPerformance; + newPerformance = new TrainHoppingPerformance( + current.currentTrain, + nextRoute.performance, + onSwitchedTrain: () { + assert(forwardPerformance.masterPerformance == newPerformance); + assert(newPerformance.currentTrain == nextRoute.performance); + forwardPerformance.masterPerformance = newPerformance.currentTrain; + newPerformance.dispose(); + } + ); + forwardPerformance.masterPerformance = newPerformance; + current.dispose(); + } else { + forwardPerformance.masterPerformance = new TrainHoppingPerformance(current, nextRoute.performance); + } + } else { + forwardPerformance.masterPerformance = nextRoute.performance; + } + } + super.didPushNext(nextRoute); + } + + Widget wrapTransition(BuildContext context, Widget child) { + return buildForwardTransition( + context, + forwardPerformance, + buildTransition( + context, + performance, + child + ) + ); + } + + Widget buildTransition(BuildContext context, PerformanceView performance, Widget child) => child; + Widget buildForwardTransition(BuildContext context, PerformanceView performance, Widget child) => child; + String get debugLabel => '$runtimeType'; String toString() => '$runtimeType(performance: $_performance)'; } @@ -246,7 +295,7 @@ class _ModalScope extends StatusTransitionComponent { key: new GlobalObjectKey(route), child: new IgnorePointer( ignoring: performance.status == PerformanceStatus.reverse, - child: route.buildTransition(context, performance, contents) + child: route.wrapTransition(context, contents) ) ); } @@ -354,7 +403,7 @@ abstract class ModalRoute extends TransitionRoute with LocalHistoryRoute new _TransitionState(); + + void debugFillDescription(List description) { + super.debugFillDescription(description); + description.add('performance: $performance'); + } } class _TransitionState extends State { diff --git a/packages/unit/test/widget/page_forward_transitions_test.dart b/packages/unit/test/widget/page_forward_transitions_test.dart new file mode 100644 index 0000000000..1d5d2448fa --- /dev/null +++ b/packages/unit/test/widget/page_forward_transitions_test.dart @@ -0,0 +1,165 @@ +// Copyright 2015 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/material.dart'; +import 'package:test/test.dart'; + +import 'test_widgets.dart'; + +class TestTransition extends TransitionComponent { + TestTransition({ + Key key, + this.childFirstHalf, + this.childSecondHalf, + PerformanceView performance + }) : super(key: key, performance: performance); + + final Widget childFirstHalf; + final Widget childSecondHalf; + + Widget build(BuildContext context) { + if (performance.progress >= 0.5) + return childSecondHalf; + return childFirstHalf; + } +} + +void main() { + final Duration kTwoTenthsOfTheTransitionDuration = kMaterialPageRouteTransitionDuration * 0.2; + final Duration kFourTenthsOfTheTransitionDuration = kMaterialPageRouteTransitionDuration * 0.4; + + test('Check onstage/offstage handling around transitions', () { + testWidgets((WidgetTester tester) { + + GlobalKey insideKey = new GlobalKey(); + + String state() { + String result = ''; + if (tester.findText('A') != null) + result += 'A'; + if (tester.findText('B') != null) + result += 'B'; + if (tester.findText('C') != null) + result += 'C'; + if (tester.findText('D') != null) + result += 'D'; + if (tester.findText('E') != null) + result += 'E'; + if (tester.findText('F') != null) + result += 'F'; + if (tester.findText('G') != null) + result += 'G'; + return result; + } + + tester.pumpWidget( + new MaterialApp( + routes: { + '/': (RouteArguments args) { + return new Builder( + key: insideKey, + builder: (BuildContext context) { + PageRoute route = ModalRoute.of(context); + return new Column([ + new TestTransition( + childFirstHalf: new Text('A'), + childSecondHalf: new Text('B'), + performance: route.performance + ), + new TestTransition( + childFirstHalf: new Text('C'), + childSecondHalf: new Text('D'), + performance: route.forwardPerformance + ), + ]); + } + ); + }, + '/2': (RouteArguments args) => new Text('E'), + '/3': (RouteArguments args) => new Text('F'), + '/4': (RouteArguments args) => new Text('G'), + } + ) + ); + + // TODO(ianh): Remove the first part of this test once the first page doesn't animate in + + NavigatorState navigator = insideKey.currentContext.ancestorStateOfType(NavigatorState); + + expect(state(), equals('AC')); // transition ->1 is at 0.0 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('AC')); // transition ->1 is at 0.4 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BC')); // transition ->1 is at 0.8 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BC')); // transition ->1 is at 1.0 + + + navigator.openTransaction((NavigatorTransaction transaction) => transaction.pushNamed('/2')); + expect(state(), equals('BC')); // transition 1->2 is not yet built + tester.pump(); + expect(state(), equals('BCE')); // transition 1->2 is at 0.0 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BCE')); // transition 1->2 is at 0.4 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BDE')); // transition 1->2 is at 0.8 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('E')); // transition 1->2 is at 1.0 + + + navigator.openTransaction((NavigatorTransaction transaction) => transaction.pop()); + expect(state(), equals('E')); // transition 1<-2 is at 1.0, just reversed + tester.pump(); + expect(state(), equals('BDE')); // transition 1<-2 is at 1.0 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BDE')); // transition 1<-2 is at 0.6 + + navigator.openTransaction((NavigatorTransaction transaction) => transaction.pushNamed('/3')); + expect(state(), equals('BDE')); // transition 1<-2 is at 0.6 + tester.pump(); + expect(state(), equals('BDEF')); // transition 1<-2 is at 0.6, 1->3 is at 0.0 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BCEF')); // transition 1<-2 is at 0.2, 1->3 is at 0.4 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BDF')); // transition 1<-2 is done, 1->3 is at 0.8 + + navigator.openTransaction((NavigatorTransaction transaction) => transaction.pop()); + expect(state(), equals('BDF')); // transition 1<-3 is at 0.8, just reversed + tester.pump(); + expect(state(), equals('BDF')); // transition 1<-3 is at 0.8 + + tester.pump(kTwoTenthsOfTheTransitionDuration); // notice that dT=0.2 here, not 0.4 + expect(state(), equals('BDF')); // transition 1<-3 is at 0.6 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BCF')); // transition 1<-3 is at 0.2 + + navigator.openTransaction((NavigatorTransaction transaction) => transaction.pushNamed('/4')); + expect(state(), equals('BCF')); // transition 1<-3 is at 0.2, 1->4 is not yet built + tester.pump(); + expect(state(), equals('BCFG')); // transition 1<-3 is at 0.2, 1->4 is at 0.0 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BCG')); // transition 1<-3 is done, 1->4 is at 0.4 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('BDG')); // transition 1->4 is at 0.8 + + tester.pump(kFourTenthsOfTheTransitionDuration); + expect(state(), equals('G')); // transition 1->4 is done + + }); + }); +} diff --git a/packages/unit/test/widget/snack_bar_test.dart b/packages/unit/test/widget/snack_bar_test.dart index f3a11bd451..a780b08cb7 100644 --- a/packages/unit/test/widget/snack_bar_test.dart +++ b/packages/unit/test/widget/snack_bar_test.dart @@ -6,11 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart'; import 'package:test/test.dart'; -class Builder extends StatelessComponent { - Builder({ this.builder }); - final WidgetBuilder builder; - Widget build(BuildContext context) => builder(context); -} +import 'test_widgets.dart'; void main() { test('SnackBar control test', () { diff --git a/packages/unit/test/widget/test_widgets.dart b/packages/unit/test/widget/test_widgets.dart index 9eaaaf4d78..c08ded9fac 100644 --- a/packages/unit/test/widget/test_widgets.dart +++ b/packages/unit/test/widget/test_widgets.dart @@ -59,3 +59,10 @@ void flipStatefulComponent(WidgetTester tester) { FlipComponentState state = stateElement.state; state.flip(); } + + +class Builder extends StatelessComponent { + Builder({ Key key, this.builder }) : super(key: key); + final WidgetBuilder builder; + Widget build(BuildContext context) => builder(context); +}