From ef004ae97def719c577c4a9141b524b8065ed595 Mon Sep 17 00:00:00 2001 From: Hans Muller Date: Tue, 14 Feb 2017 08:20:16 -0800 Subject: [PATCH] New Heroes (#8112) --- examples/stocks/lib/stock_symbol_viewer.dart | 1 - packages/flutter/lib/src/widgets/heroes.dart | 801 ++++++++---------- .../flutter/test/widgets/heroes_test.dart | 32 + .../flutter/test/widgets/navigator_test.dart | 1 + .../page_forward_transitions_test.dart | 2 + .../test/widgets/page_transitions_test.dart | 1 + 6 files changed, 385 insertions(+), 453 deletions(-) diff --git a/examples/stocks/lib/stock_symbol_viewer.dart b/examples/stocks/lib/stock_symbol_viewer.dart index 0b12771e17e..b080a4ac150 100644 --- a/examples/stocks/lib/stock_symbol_viewer.dart +++ b/examples/stocks/lib/stock_symbol_viewer.dart @@ -81,7 +81,6 @@ class StockSymbolPage extends StatelessWidget { stock: stock, arrow: new Hero( tag: stock, - turns: 2, child: new StockArrow(percentChange: stock.percentChange) ) ) diff --git a/packages/flutter/lib/src/widgets/heroes.dart b/packages/flutter/lib/src/widgets/heroes.dart index 4af22e4972a..d564eed6337 100644 --- a/packages/flutter/lib/src/widgets/heroes.dart +++ b/packages/flutter/lib/src/widgets/heroes.dart @@ -2,8 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:collection'; - import 'package:flutter/foundation.dart'; import 'basic.dart'; @@ -14,71 +12,54 @@ import 'overlay.dart'; import 'pages.dart'; import 'transitions.dart'; -// TODO(ianh): Make the appear/disappear animations pretty. Right now they're -// pretty crude (just rotate and shrink the constraints). They should probably -// involve actually scaling and fading, at a minimum. +/// Signature for a function that takes two [Rect] instances and returns a +/// [RectTween] that transitions between them. +/// +/// This is typically used with a [HeroController] to provide an animation for +/// [Hero] positions that looks nicer than a linear movement. For example, see +/// [MaterialRectArcTween]. +typedef RectTween CreateRectTween(Rect begin, Rect end); -// TODO(ianh): If the widgets use Inherited properties, they are taken from the -// Navigator's position in the widget hierarchy, not the source or target. We -// should interpolate the inherited properties from their value at the source to -// their value at the target. See: https://github.com/flutter/flutter/issues/213 +typedef void _OnFlightEnded(_HeroFlight flight); -class _HeroManifest { - const _HeroManifest({ - this.key, - this.config, - this.sourceStates, - this.currentRect, - this.currentTurns - }); - final GlobalKey key; - final Widget config; - final Set<_HeroState> sourceStates; - final Rect currentRect; - final double currentTurns; +enum _HeroFlightType { + push, // Fly the "to" hero and animate with the "to" route. + pop, // Fly the "to" hero and animate with the "from" route. } -abstract class _HeroHandle { - bool get alwaysAnimate; - _HeroManifest _takeChild(Animation currentAnimation); +// The bounding box for context in global coordinates. +Rect _globalRect(BuildContext context) { + final RenderBox box = context.findRenderObject(); + assert(box != null && box.hasSize); + return MatrixUtils.transformRect(box.getTransformTo(null), Point.origin & box.size); } /// A widget that marks its child as being a candidate for hero animations. /// -/// During a page transition (see [Navigator]), if a particular feature (e.g. a -/// picture or heading) appears on both pages, it can be helpful for orienting -/// the user if the feature appears to physically move from one page to the -/// other. Such an animation is called a *hero animation*. +/// When a [PageRoute] is pushed or popped with the [Navigator], the entire +/// screen's content is replaced. An old route disappears and a new route +/// appears. If there's a common visual feature on both routes then it can +/// be helpful for orienting the user for the feature to physically move from +/// one page to the other during the routes' transition. Such an animation +/// is called a *hero animation*. The hero widgets "fly" in the Navigator's +/// overlay during the transition and while they're in-flight they're +/// not shown in their original locations in the old and new routes. /// -/// To label a widget as such a feature, wrap it in a [Hero] widget. When a -/// navigation happens, the [Hero] widgets on each page are collected up. For -/// each pair of [Hero] widgets that have the same tag, a hero animation is -/// triggered. +/// To label a widget as such a feature, wrap it in a [Hero] widget. When +/// navigation happens, the [Hero] widgets on each route are identified +/// by the [HeroController]. For each pair of [Hero] widgets that have the +/// same tag, a hero animation is triggered. /// -/// Hero animations are managed by a [HeroController]. +/// If a [Hero] is already in flight when navigation occurs, its +/// flight animation will be redirected to its new destination. /// -/// If a [Hero] is already in flight when another navigation occurs, then it -/// will continue to the next page. -/// -/// A particular page must not have more than one [Hero] for each [tag]. +/// Routes must not contain more than one [Hero] for each [tag]. /// /// ## Discussion /// -/// Heroes are the parts of an application's screen-to-screen transitions where -/// a widget from one screen shifts to a position on the other. For example, -/// album art from a list of albums growing to become the centerpiece of the -/// album's details view. In this context, a screen is a [ModalRoute] in a -/// [Navigator]. -/// -/// To get this effect, all you have to do is wrap each hero on each route with -/// a [Hero] widget, and give each hero a [tag]. The tag must be unique within -/// the current route's widget subtree, and must match the tag of a [Hero] in -/// the target route. When the app transitions from one route to another, each -/// hero is animated to its new location. -/// -/// Heroes and the [Navigator]'s [Overlay]'s [Stack] must be axis-aligned for +/// Heroes and the [Navigator]'s [Overlay] [Stack] must be axis-aligned for /// all this to work. The top left and bottom right coordinates of each animated -/// [Hero] will be converted to global coordinates and then from there converted +/// Hero will be converted to global coordinates and then from there converted /// to that [Stack]'s coordinate space, and the entire Hero subtree will, for /// the duration of the animation, be lifted out of its original place, and /// positioned on that stack. If the [Hero] isn't axis aligned, this is going to @@ -95,56 +76,43 @@ abstract class _HeroHandle { class Hero extends StatefulWidget { /// Create a hero. /// - /// The [tag] and [child] are required. + /// The [tag] and [child] parameters must not be null. Hero({ Key key, @required this.tag, - this.turns: 1, - this.alwaysAnimate: false, @required this.child, }) : super(key: key) { assert(tag != null); - assert(turns != null); - assert(alwaysAnimate != null); assert(child != null); } /// The identifier for this particular hero. If the tag of this hero matches - /// the tag of a hero on the other page during a page transition, then a hero - /// animation will be triggered. + /// the tag of a hero on a [PageRoute] that we're navigating to or from, then + /// a hero animation will be triggered. final Object tag; - /// The relative number of full rotations that the hero is conceptually at. + /// The widget subtree that will "fly" from one route to another during a + /// [Naviator] push or pop transition. /// - /// If a hero is animated from a [Hero] with [turns] set to 1 to a [Hero] with - /// [turns] set to 2, then it will turn by one full rotation during its - /// animation. Normally, all heroes have a [turns] value of 1. - final int turns; - - /// If true, the hero will always animate, even if it has no matching hero to - /// animate to or from. If it has no target, it will imply a target at the - /// same position with zero width and height and with [turns] set to zero. - /// This will typically cause it to shrink and spin. - final bool alwaysAnimate; - - /// The widget below this widget in the tree. - /// - /// This subtree should match the appearance of the subtrees of any other - /// heroes in the application with the same [tag]. + /// The appearance of this subtree should be similar to the appearance of + /// the subtrees of any other heroes in the application with the same [tag]. + /// Changes in scale and aspect ratio work well in hero animations, changes + /// in layout or composition do not. final Widget child; - /// Return a hero tag to _HeroState map of all of the heroes within the given subtree. - static Map _of(BuildContext context) { - final Map result = {}; + // Returns a map of all of the heroes in context, indexed by hero tag. + static Map _allHeroesFor(BuildContext context) { + assert(context != null); + final Map result = {}; void visitor(Element element) { if (element.widget is Hero) { - StatefulElement hero = element; - Hero heroWidget = element.widget; - Object tag = heroWidget.tag; + final StatefulElement hero = element; + final Hero heroWidget = element.widget; + final Object tag = heroWidget.tag; assert(tag != null); assert(() { if (result.containsKey(tag)) { - new FlutterError( + throw new FlutterError( 'There are multiple heroes that share the same tag within a subtree.\n' 'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), ' 'each Hero must have a unique non-null tag.\n' @@ -166,299 +134,270 @@ class Hero extends StatefulWidget { _HeroState createState() => new _HeroState(); } -class _HeroState extends State implements _HeroHandle { +class _HeroState extends State { GlobalKey _key = new GlobalKey(); Size _placeholderSize; - VoidCallback _disposeCallback; - @override - bool get alwaysAnimate => config.alwaysAnimate; - - @override - _HeroManifest _takeChild(Animation currentAnimation) { - assert(mounted); - final RenderBox renderObject = context.findRenderObject(); - assert(renderObject != null); - assert(!renderObject.debugNeedsLayout); - assert(renderObject.hasSize); - if (_placeholderSize == null) { - // We are a "from" hero, about to depart on a quest. - // Remember our size so that we can leave a placeholder. - _placeholderSize = renderObject.size; - } - final Point heroTopLeft = renderObject.localToGlobal(Point.origin); - final Point heroBottomRight = renderObject.localToGlobal(renderObject.size.bottomRight(Point.origin)); - final Rect heroArea = new Rect.fromLTRB(heroTopLeft.x, heroTopLeft.y, heroBottomRight.x, heroBottomRight.y); - _HeroManifest result = new _HeroManifest( - key: _key, // might be null, e.g. if the hero is returning to us - config: config, - sourceStates: new HashSet<_HeroState>.from(<_HeroState>[this]), - currentRect: heroArea, - currentTurns: config.turns.toDouble() - ); - if (_key != null) - setState(() { _key = null; }); - return result; - } - - void _setChild(GlobalKey value) { - assert(_key == null); - assert(_placeholderSize != null); - assert(value != null); + void startFlight() { assert(mounted); + final RenderBox box = context.findRenderObject(); + assert(box != null && box.hasSize); setState(() { - _key = value; - _placeholderSize = null; + _placeholderSize = box.size; }); } - void _resetChild() { - if (mounted) - _setChild(new GlobalKey()); - } - - @override - void dispose() { - if (_disposeCallback != null) - _disposeCallback(); - super.dispose(); + void endFlight() { + if (mounted) { + setState(() { + _placeholderSize = null; + }); + } } @override Widget build(BuildContext context) { if (_placeholderSize != null) { - assert(_key == null); - return new SizedBox(width: _placeholderSize.width, height: _placeholderSize.height); + return new SizedBox( + width: _placeholderSize.width, + height: _placeholderSize.height + ); } return new KeyedSubtree( key: _key, - child: config.child + child: config.child, ); } } - -class _HeroQuestState implements _HeroHandle { - _HeroQuestState({ - this.tag, - this.key, - this.child, - this.sourceStates, - this.animationArea, - this.targetRect, - this.targetTurns, - this.targetState, - this.currentRect, - this.currentTurns +// Everything known about a hero flight that's to be started or restarted. +class _HeroFlightManifest { + _HeroFlightManifest({ + @required this.type, + @required this.overlay, + @required this.navigatorRect, + @required this.fromRoute, + @required this.toRoute, + @required this.fromHero, + @required this.toHero, + @required this.createRectTween, }) { - assert(tag != null); - for (_HeroState state in sourceStates) - state._disposeCallback = () => sourceStates.remove(state); - if (targetState != null) - targetState._disposeCallback = _handleTargetStateDispose; + assert(fromHero.config.tag == toHero.config.tag); } - final Object tag; - final GlobalKey key; - final Widget child; - final Set<_HeroState> sourceStates; - final Rect animationArea; - Rect targetRect; - int targetTurns; - _HeroState targetState; - final RectTween currentRect; - final Tween currentTurns; - - OverlayEntry overlayEntry; - - @override - bool get alwaysAnimate => true; - - bool get taken => _taken; - bool _taken = false; - - @override - _HeroManifest _takeChild(Animation currentAnimation) { - assert(!taken); - _taken = true; - Set<_HeroState> states = sourceStates; - if (targetState != null) - states = states.union(new HashSet<_HeroState>.from(<_HeroState>[targetState])); - for (_HeroState state in states) - state._disposeCallback = null; - return new _HeroManifest( - key: key, - config: child, - sourceStates: states, - currentRect: currentRect.evaluate(currentAnimation), - currentTurns: currentTurns.evaluate(currentAnimation) - ); - } - - void _handleTargetStateDispose() { - targetState = null; - targetTurns = 0; - targetRect = targetRect.center & Size.zero; - WidgetsBinding.instance.addPostFrameCallback((Duration d) => overlayEntry.markNeedsBuild()); - } - - Widget build(BuildContext context, Animation animation) { - return new RelativePositionedTransition( - rect: currentRect.animate(animation), - size: animationArea.size, - child: new RotationTransition( - turns: currentTurns.animate(animation), - child: new IgnorePointer( - child: new RepaintBoundary( - key: key, - child: child - ) - ) - ) - ); - } - - @mustCallSuper - void dispose() { - overlayEntry = null; - for (_HeroState state in sourceStates) - state._disposeCallback = null; - if (targetState != null) - targetState._disposeCallback = null; - } -} - -class _HeroMatch { - const _HeroMatch(this.from, this.to, this.tag); - final _HeroHandle from; - final _HeroHandle to; - final Object tag; -} - -/// Signature for a function that takes two [Rect] instances and returns a -/// [RectTween] that transitions between them. -/// -/// This is typically used with a [HeroController] to provide an animation for -/// [Hero] positions that looks nicer than a linear movement. For example, see -/// [MaterialRectArcTween]. -typedef RectTween CreateRectTween(Rect begin, Rect end); - -class _HeroParty { - _HeroParty({ this.onQuestFinished, this.createRectTween }); - - final VoidCallback onQuestFinished; + final _HeroFlightType type; + final OverlayState overlay; + final Rect navigatorRect; + final PageRoute fromRoute; + final PageRoute toRoute; + final _HeroState fromHero; + final _HeroState toHero; final CreateRectTween createRectTween; - List<_HeroQuestState> _heroes = <_HeroQuestState>[]; - bool get isEmpty => _heroes.isEmpty; + Object get tag => fromHero.config.tag; - Map getHeroesToAnimate() { - Map result = new Map(); - for (_HeroQuestState hero in _heroes) - result[hero.tag] = hero; - assert(!result.containsKey(null)); - return result; + Animation get animation { + return new CurvedAnimation( + parent: (type == _HeroFlightType.push) ? toRoute.animation : fromRoute.animation, + curve: Curves.fastOutSlowIn, + ); } + @override + String toString() { + return '_HeroFlightManifest($type hero: $tag from: ${fromRoute.settings} to: ${toRoute.settings})'; + } +} + +// Builds the in-flight hero widget. +class _HeroFlight { + _HeroFlight(this.onFlightEnded) { + _proxyAnimation = new ProxyAnimation()..addStatusListener(_handleAnimationUpdate); + } + + final _OnFlightEnded onFlightEnded; + + RectTween heroRect; + Animation _heroOpacity = kAlwaysCompleteAnimation; + ProxyAnimation _proxyAnimation; + _HeroFlightManifest manifest; + OverlayEntry overlayEntry; + RectTween _doCreateRectTween(Rect begin, Rect end) { - if (createRectTween != null) - return createRectTween(begin, end); + if (manifest.createRectTween != null) + return manifest.createRectTween(begin, end); return new RectTween(begin: begin, end: end); } - Tween createTurnsTween(double begin, double end) { - assert(end.floor() == end); - return new Tween(begin: begin, end: end); + // The OverlayEntry WidgetBuilder callback for the hero's overlay. + Widget _buildOverlay(BuildContext context) { + assert(manifest != null); + return new AnimatedBuilder( + animation: _proxyAnimation, + child: manifest.toHero.config, + builder: (BuildContext context, Widget child) { + final RenderBox toHeroBox = manifest.toHero.context?.findRenderObject(); + if (toHeroBox == null || !toHeroBox.attached) { + // The toHero no longer exists. Continue flying while fading out. + if (_heroOpacity == kAlwaysCompleteAnimation) { + _heroOpacity = new Tween(begin: 1.0, end: 0.0) + .chain(new CurveTween(curve: new Interval(_proxyAnimation.value, 1.0))) + .animate(_proxyAnimation); + } + } else if (toHeroBox.hasSize) { + // The toHero has been laid out. If it's no longer where the hero animation is + // supposed to end up (heroRect.end) then recreate the heroRect tween. + final RenderBox routeBox = manifest.toRoute.subtreeContext?.findRenderObject(); + final Point heroOriginEnd = toHeroBox.localToGlobal(Point.origin, ancestor: routeBox); + if (heroOriginEnd != heroRect.end.topLeft) { + final Rect heroRectEnd = heroOriginEnd & heroRect.end.size; + heroRect = _doCreateRectTween(heroRect.begin, heroRectEnd); + } + } + + final Rect rect = heroRect.evaluate(_proxyAnimation); + final Size size = manifest.navigatorRect.size; + final RelativeRect offsets = new RelativeRect.fromSize(rect, size); + + return new Positioned( + top: offsets.top, + right: offsets.right, + bottom: offsets.bottom, + left: offsets.left, + child: new IgnorePointer( + child: new RepaintBoundary( + child: new Opacity( + key: manifest.toHero._key, + opacity: _heroOpacity.value, + child: child, + ), + ), + ), + ); + }, + ); } - void animate(Map heroesFrom, Map heroesTo, Rect animationArea) { - assert(!heroesFrom.containsKey(null)); - assert(!heroesTo.containsKey(null)); + void _handleAnimationUpdate(AnimationStatus status) { + if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { + _proxyAnimation.parent = null; - // make a list of pairs of heroes, based on the from and to lists - Map heroes = {}; - for (Object tag in heroesFrom.keys) - heroes[tag] = new _HeroMatch(heroesFrom[tag], heroesTo[tag], tag); - for (Object tag in heroesTo.keys) { - if (!heroes.containsKey(tag)) - heroes[tag] = new _HeroMatch(heroesFrom[tag], heroesTo[tag], tag); - } + assert(overlayEntry != null); + overlayEntry.remove(); + overlayEntry = null; - // create a heroating hero out of each pair - final List<_HeroQuestState> _newHeroes = <_HeroQuestState>[]; - for (_HeroMatch heroPair in heroes.values) { - assert(heroPair.from != null || heroPair.to != null); - if ((heroPair.from == null && !heroPair.to.alwaysAnimate) || - (heroPair.to == null && !heroPair.from.alwaysAnimate)) - continue; - _HeroManifest from = heroPair.from?._takeChild(_currentAnimation); - assert(heroPair.to == null || heroPair.to is _HeroState); - _HeroManifest to = heroPair.to?._takeChild(_currentAnimation); - assert(from != null || to != null); - assert(to == null || to.sourceStates.length == 1); - assert(to == null || to.currentTurns.floor() == to.currentTurns); - _HeroState targetState = to != null ? to.sourceStates.elementAt(0) : null; - Set<_HeroState> sourceStates = from?.sourceStates ?? new HashSet<_HeroState>(); - sourceStates.remove(targetState); - Rect sourceRect = from?.currentRect ?? to.currentRect.center & Size.zero; - Rect targetRect = to?.currentRect ?? from.currentRect.center & Size.zero; - double sourceTurns = from?.currentTurns ?? 0.0; - double targetTurns = to?.currentTurns ?? 0.0; - _newHeroes.add(new _HeroQuestState( - tag: heroPair.tag, - key: from?.key ?? to.key, - child: to?.config ?? from.config, - sourceStates: sourceStates, - animationArea: animationArea, - targetRect: targetRect, - targetTurns: targetTurns.floor(), - targetState: targetState, - currentRect: _doCreateRectTween(sourceRect, targetRect), - currentTurns: createTurnsTween(sourceTurns, targetTurns) - )); - } - - assert(!_heroes.any((_HeroQuestState hero) => !hero.taken)); - for (_HeroQuestState hero in _heroes) - hero.dispose(); - _heroes = _newHeroes; - } - - Animation _currentAnimation; - - void _clearCurrentAnimation() { - _currentAnimation?.removeStatusListener(_handleUpdate); - _currentAnimation = null; - } - - void setAnimation(Animation animation) { - assert(animation != null || _heroes.isEmpty); - if (animation != _currentAnimation) { - _clearCurrentAnimation(); - _currentAnimation = animation; - _currentAnimation?.addStatusListener(_handleUpdate); + manifest.fromHero.endFlight(); + manifest.toHero.endFlight(); + onFlightEnded(this); } } - void _handleUpdate(AnimationStatus status) { - if (status == AnimationStatus.completed || - status == AnimationStatus.dismissed) { - for (_HeroQuestState hero in _heroes) { - if (hero.targetState != null) - hero.targetState._setChild(hero.key); - for (_HeroState source in hero.sourceStates) - source._resetChild(); - hero.dispose(); + // The simple case: we're either starting a push or a pop animation. + void start(_HeroFlightManifest initialManifest) { + assert(() { + final Animation initial = initialManifest.animation; + switch (initialManifest.type) { + case _HeroFlightType.pop: + return initial.value == 1.0 && initial.status == AnimationStatus.reverse; + case _HeroFlightType.push: + return initial.value == 0.0 && initial.status == AnimationStatus.forward; } - _heroes.clear(); - _clearCurrentAnimation(); - if (onQuestFinished != null) - onQuestFinished(); + }); + + manifest = initialManifest; + if (manifest.type == _HeroFlightType.pop) + _proxyAnimation.parent = new ReverseAnimation(manifest.animation); + else + _proxyAnimation.parent = manifest.animation; + + manifest.fromHero.startFlight(); + manifest.toHero.startFlight(); + + heroRect = new RectTween( + begin: _globalRect(manifest.fromHero.context), + end: _globalRect(manifest.toHero.context), + ); + + overlayEntry = new OverlayEntry(builder: _buildOverlay); + manifest.overlay.insert(overlayEntry); + } + + // While this flight's hero was in transition a push or a pop occurred for + // routes with the same hero. Redirect the in-flight hero to the new toRoute. + void restart(_HeroFlightManifest newManifest) { + assert(manifest.tag == newManifest.tag); + + if (manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.pop) { + // A push flight was interrupted by a pop. + assert(newManifest.animation.status == AnimationStatus.reverse); + assert(manifest.fromHero == newManifest.toHero); + assert(manifest.toHero == newManifest.fromHero); + assert(manifest.fromRoute == newManifest.toRoute); + assert(manifest.toRoute == newManifest.fromRoute); + + _proxyAnimation.parent = new ReverseAnimation(newManifest.animation); + + heroRect = new RectTween( + begin: heroRect.end, + end: heroRect.begin, + ); + } else if (manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.push) { + // A pop flight was interrupted by a push. + assert(newManifest.animation.status == AnimationStatus.forward); + assert(manifest.toHero == newManifest.fromHero); + assert(manifest.toRoute == newManifest.fromRoute); + + _proxyAnimation.parent = new Tween( + begin: manifest.animation.value, + end: 1.0, + ).animate(newManifest.animation); + + if (manifest.fromHero != newManifest.toHero) { + manifest.fromHero.endFlight(); + newManifest.toHero.startFlight(); + heroRect = new RectTween( + begin: heroRect.end, + end: _globalRect(newManifest.toHero.context), + ); + } else { + heroRect = new RectTween( + begin: heroRect.end, + end: heroRect.begin, + ); + } + } else { + // A push or a pop flight is heading to a new route, i.e. + // manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.push || + // manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.pop + assert(manifest.fromHero != newManifest.fromHero); + assert(manifest.toHero != newManifest.toHero); + + heroRect = new RectTween( + begin: heroRect.evaluate(_proxyAnimation), + end: _globalRect(newManifest.toHero.context), + ); + + if (newManifest.type == _HeroFlightType.pop) + _proxyAnimation.parent = new ReverseAnimation(newManifest.animation); + else + _proxyAnimation.parent = newManifest.animation; + + manifest.fromHero.endFlight(); + manifest.toHero.endFlight(); + newManifest.fromHero.startFlight(); + newManifest.toHero.startFlight(); } + + manifest = newManifest; } @override - String toString() => '$_heroes'; + String toString() { + final RouteSettings from = manifest.fromRoute.settings; + final RouteSettings to = manifest.toRoute.settings; + final Object tag = manifest.tag; + return 'HeroFlight(for: $tag, from: $from, to: $to ${_proxyAnimation.parent})'; + } } /// A [Navigator] observer that manages [Hero] transitions. @@ -468,59 +407,34 @@ class _HeroParty { class HeroController extends NavigatorObserver { /// Creates a hero controller with the given [RectTween] constructor if any. /// - /// The [createRectTween] argument is optional. By default, a linear + /// The [createRectTween] argument is optional. If null, a linear /// [RectTween] is used. - HeroController({ CreateRectTween createRectTween }) { - _party = new _HeroParty( - onQuestFinished: _handleQuestFinished, - createRectTween: createRectTween - ); - } + HeroController({ this.createRectTween }); - // The current party, if they're on a quest. - _HeroParty _party; - - // The settings used to prepare the next quest. - // These members are only non-null between the didPush/didPop call and the - // corresponding _updateQuest call. - PageRoute _from; - PageRoute _to; - Animation _animation; - - final List _overlayEntries = new List(); - - @override - void didPush(Route route, Route previousRoute) { - assert(navigator != null); - assert(route != null); - if (_questsEnabled && route is PageRoute) { - assert(route.animation != null); - if (previousRoute is PageRoute) // could be null - _from = previousRoute; - _to = route; - _animation = route.animation; - _checkForHeroQuest(); - } - } - - @override - void didPop(Route route, Route previousRoute) { - assert(navigator != null); - assert(route != null); - if (_questsEnabled && route is PageRoute) { - assert(route.animation != null); - if (route.animation.status != AnimationStatus.dismissed && previousRoute is PageRoute) { - _from = route; - _to = previousRoute; - _animation = route.animation; - _checkForHeroQuest(); - } - } - } + final CreateRectTween createRectTween; // Disable Hero animations while a user gesture is controlling the navigation. bool _questsEnabled = true; + // All of the heroes that are currently in the overlay and in motion. + // Indexed by the hero tag. + // TBD: final? + Map _flights = {}; + + @override + void didPush(Route to, Route from) { + assert(navigator != null); + assert(to != null); + _maybeStartHeroTransition(from, to, _HeroFlightType.push); + } + + @override + void didPop(Route from, Route to) { + assert(navigator != null); + assert(from != null); + _maybeStartHeroTransition(from, to, _HeroFlightType.pop); + } + @override void didStartUserGesture() { _questsEnabled = false; @@ -531,89 +445,72 @@ class HeroController extends NavigatorObserver { _questsEnabled = true; } - void _checkForHeroQuest() { - assert(_questsEnabled); - if (_from != null && _to != null && _from != _to) { - assert(_animation != null); - _to.offstage = _to.animation.status != AnimationStatus.completed; - _questsEnabled = false; - WidgetsBinding.instance.addPostFrameCallback(_updateQuest); - } else { - // this isn't a valid quest - _clearPendingHeroQuest(); + // If we're transitioning between different page routes, start a hero transition + // after the toRoute has been laid out with its animation's value at 1.0. + void _maybeStartHeroTransition(Route fromRoute, Route toRoute, _HeroFlightType flightType) { + if (_questsEnabled && toRoute != fromRoute && toRoute is PageRoute && fromRoute is PageRoute) { + final PageRoute from = fromRoute; + final PageRoute to = toRoute; + final Animation animation = (flightType == _HeroFlightType.push) ? to.animation : from.animation; + + // A "user" gesture may have already completed the pop. + if (flightType == _HeroFlightType.pop && animation.status == AnimationStatus.dismissed) + return; + + // Putting a route offstage changes its animation value to 1.0. + // Once this frame completes, we'll know where the heroes in the toRoute + // are going to end up, and the toRoute will go back on stage. + to.offstage = animation.value == 0.0 || animation.value == 1.0; + + WidgetsBinding.instance.addPostFrameCallback((Duration _) { + _startHeroTransition(from, to, flightType); + }); } } - void _handleQuestFinished() { - _removeHeroesFromOverlay(); - } - - Rect _getAnimationArea(BuildContext context) { - RenderBox box = context.findRenderObject(); - Point topLeft = box.localToGlobal(Point.origin); - Point bottomRight = box.localToGlobal(box.size.bottomRight(Point.origin)); - return new Rect.fromLTRB(topLeft.x, topLeft.y, bottomRight.x, bottomRight.y); - } - - void _removeHeroesFromOverlay() { - for (OverlayEntry entry in _overlayEntries) - entry.remove(); - _overlayEntries.clear(); - } - - OverlayEntry _addHeroToOverlay(WidgetBuilder hero, Object tag, OverlayState overlay) { - OverlayEntry entry = new OverlayEntry(builder: hero); - assert(_animation.status != AnimationStatus.dismissed && _animation.status != AnimationStatus.completed); - if (_animation.status == AnimationStatus.forward) - _to.insertHeroOverlayEntry(entry, tag, overlay); - else - _from.insertHeroOverlayEntry(entry, tag, overlay); - _overlayEntries.add(entry); - return entry; - } - - void _updateQuest(Duration timeStamp) { - assert(!_questsEnabled); - if (navigator == null) { - // The navigator was removed before this end-of-frame callback was called. - _clearPendingHeroQuest(); + // Find the matching pairs of heros in from and to and either start or a new + // hero flight, or restart an existing one. + void _startHeroTransition(PageRoute from, PageRoute to, _HeroFlightType flightType) { + // The navigator or one of the routes subtrees was removed before this + // end-of-frame callback was called then don't actually start a transition. + // TBD: need to generate tests for these cases + if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) { + to.offstage = false; // TBD: only do this if to.subtreeContext != null? return; } - assert(_from.subtreeContext != null); - assert(_to.subtreeContext != null); - Map heroesFrom = _party.isEmpty ? - Hero._of(_from.subtreeContext) : _party.getHeroesToAnimate(); - Map heroesTo = Hero._of(_to.subtreeContext); - _to.offstage = false; + final Rect navigatorRect = _globalRect(navigator.context); - Animation animation = _animation; // The route's animation. - Curve curve = Curves.fastOutSlowIn; - if (animation.status == AnimationStatus.reverse) { - animation = new ReverseAnimation(animation); - curve = new Interval(animation.value, 1.0, curve: curve); + // At this point the toHeroes may have been built and laid out for the first time. + final Map fromHeroes = Hero._allHeroesFor(from.subtreeContext); + final Map toHeroes = Hero._allHeroesFor(to.subtreeContext); + + // If the to route was offstage, then we're implicitly restoring its + // animation value back to what it was before it was "moved" offstage. + to.offstage = false; + + for (Object tag in fromHeroes.keys) { + if (toHeroes[tag] != null) { + final _HeroFlightManifest manifest = new _HeroFlightManifest( + type: flightType, + overlay: navigator.overlay, + navigatorRect: navigatorRect, + fromRoute: from, + toRoute: to, + fromHero: fromHeroes[tag], + toHero: toHeroes[tag], + createRectTween: createRectTween, + ); + + if (_flights[tag] != null) + _flights[tag].restart(manifest); + else + _flights[tag] = new _HeroFlight(_handleFlightEnded)..start(manifest); + } } - animation = new CurvedAnimation(parent: animation, curve: curve); - - _party.animate(heroesFrom, heroesTo, _getAnimationArea(navigator.context)); - _removeHeroesFromOverlay(); - _party.setAnimation(animation); - - for (_HeroQuestState hero in _party._heroes) { - hero.overlayEntry = _addHeroToOverlay( - (BuildContext context) => hero.build(navigator.context, animation), - hero.tag, - navigator.overlay - ); - } - - _clearPendingHeroQuest(); } - void _clearPendingHeroQuest() { - _from = null; - _to = null; - _animation = null; - _questsEnabled = true; + void _handleFlightEnded(_HeroFlight flight) { + _flights.remove(flight.manifest.tag); } } diff --git a/packages/flutter/test/widgets/heroes_test.dart b/packages/flutter/test/widgets/heroes_test.dart index 473459d7281..1ddc1f9ee97 100644 --- a/packages/flutter/test/widgets/heroes_test.dart +++ b/packages/flutter/test/widgets/heroes_test.dart @@ -357,4 +357,36 @@ void main() { // TODO(ianh): once https://github.com/flutter/flutter/issues/5631 is fixed, remove this line: await tester.pump(const Duration(hours: 1)); }); + + testWidgets('One route, two heroes, same tag, throws', (WidgetTester tester) async { + await tester.pumpWidget(new MaterialApp( + home: new Material( + child: new ListView( + children: [ + new Hero(tag: 'a', child: new Text('a')), + new Hero(tag: 'a', child: new Text('a too')), + new Builder( + builder: (BuildContext context) { + return new FlatButton( + child: new Text('push'), + onPressed: () { + Navigator.push(context, new PageRouteBuilder( + pageBuilder: (BuildContext context, Animation _, Animation __) { + return new Text('fail'); + }, + )); + }, + ); + }, + ), + ], + ), + ), + )); + + await tester.tap(find.text('push')); + await tester.pump(); + expect(tester.takeException(), isFlutterError); + }); + } diff --git a/packages/flutter/test/widgets/navigator_test.dart b/packages/flutter/test/widgets/navigator_test.dart index e3d110bfb00..be83ee5d641 100644 --- a/packages/flutter/test/widgets/navigator_test.dart +++ b/packages/flutter/test/widgets/navigator_test.dart @@ -148,6 +148,7 @@ void main() { expect(find.text('X'), findsNothing); expect(find.text('Y'), findsOneWidget); + await tester.pump(); await tester.pump(); expect(find.text('X'), findsOneWidget); expect(find.text('Y'), findsOneWidget); diff --git a/packages/flutter/test/widgets/page_forward_transitions_test.dart b/packages/flutter/test/widgets/page_forward_transitions_test.dart index e90030a41c2..6466e375406 100644 --- a/packages/flutter/test/widgets/page_forward_transitions_test.dart +++ b/packages/flutter/test/widgets/page_forward_transitions_test.dart @@ -132,6 +132,8 @@ void main() { navigator.pop(); expect(state(), equals('E')); // transition 1<-2 is at 1.0, just reversed await tester.pump(); + await tester.pump(); + expect(state(), equals('BDE')); // transition 1<-2 is at 1.0 await tester.pump(kFourTenthsOfTheTransitionDuration); diff --git a/packages/flutter/test/widgets/page_transitions_test.dart b/packages/flutter/test/widgets/page_transitions_test.dart index a979b3d2283..d420253e71e 100644 --- a/packages/flutter/test/widgets/page_transitions_test.dart +++ b/packages/flutter/test/widgets/page_transitions_test.dart @@ -113,6 +113,7 @@ void main() { expect(Navigator.canPop(containerKey2.currentContext), isTrue); Navigator.pop(containerKey2.currentContext); await tester.pump(); + await tester.pump(); expect(find.text('Home'), isOnstage); expect(find.text('Settings'), isOnstage);