Commit ef004ae9 authored by Hans Muller's avatar Hans Muller Committed by GitHub

New Heroes (#8112)

parent cd3fd475
...@@ -81,7 +81,6 @@ class StockSymbolPage extends StatelessWidget { ...@@ -81,7 +81,6 @@ class StockSymbolPage extends StatelessWidget {
stock: stock, stock: stock,
arrow: new Hero( arrow: new Hero(
tag: stock, tag: stock,
turns: 2,
child: new StockArrow(percentChange: stock.percentChange) child: new StockArrow(percentChange: stock.percentChange)
) )
) )
......
...@@ -2,8 +2,6 @@ ...@@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:collection';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'basic.dart'; import 'basic.dart';
...@@ -14,71 +12,54 @@ import 'overlay.dart'; ...@@ -14,71 +12,54 @@ import 'overlay.dart';
import 'pages.dart'; import 'pages.dart';
import 'transitions.dart'; import 'transitions.dart';
// TODO(ianh): Make the appear/disappear animations pretty. Right now they're /// Signature for a function that takes two [Rect] instances and returns a
// pretty crude (just rotate and shrink the constraints). They should probably /// [RectTween] that transitions between them.
// involve actually scaling and fading, at a minimum. ///
/// This is typically used with a [HeroController] to provide an animation for
// TODO(ianh): If the widgets use Inherited properties, they are taken from the /// [Hero] positions that looks nicer than a linear movement. For example, see
// Navigator's position in the widget hierarchy, not the source or target. We /// [MaterialRectArcTween].
// should interpolate the inherited properties from their value at the source to typedef RectTween CreateRectTween(Rect begin, Rect end);
// their value at the target. See: https://github.com/flutter/flutter/issues/213
typedef void _OnFlightEnded(_HeroFlight flight);
class _HeroManifest {
const _HeroManifest({ enum _HeroFlightType {
this.key, push, // Fly the "to" hero and animate with the "to" route.
this.config, pop, // Fly the "to" hero and animate with the "from" route.
this.sourceStates,
this.currentRect,
this.currentTurns
});
final GlobalKey key;
final Widget config;
final Set<_HeroState> sourceStates;
final Rect currentRect;
final double currentTurns;
} }
abstract class _HeroHandle { // The bounding box for context in global coordinates.
bool get alwaysAnimate; Rect _globalRect(BuildContext context) {
_HeroManifest _takeChild(Animation<double> currentAnimation); 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. /// 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 /// When a [PageRoute] is pushed or popped with the [Navigator], the entire
/// picture or heading) appears on both pages, it can be helpful for orienting /// screen's content is replaced. An old route disappears and a new route
/// the user if the feature appears to physically move from one page to the /// appears. If there's a common visual feature on both routes then it can
/// other. Such an animation is called a *hero animation*. /// 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
/// To label a widget as such a feature, wrap it in a [Hero] widget. When a /// is called a *hero animation*. The hero widgets "fly" in the Navigator's
/// navigation happens, the [Hero] widgets on each page are collected up. For /// overlay during the transition and while they're in-flight they're
/// each pair of [Hero] widgets that have the same tag, a hero animation is /// not shown in their original locations in the old and new routes.
/// triggered.
/// ///
/// Hero animations are managed by a [HeroController]. /// 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.
/// ///
/// If a [Hero] is already in flight when another navigation occurs, then it /// If a [Hero] is already in flight when navigation occurs, its
/// will continue to the next page. /// flight animation will be redirected to its new destination.
/// ///
/// 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 /// ## Discussion
/// ///
/// Heroes are the parts of an application's screen-to-screen transitions where /// Heroes and the [Navigator]'s [Overlay] [Stack] must be axis-aligned for
/// 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
/// all this to work. The top left and bottom right coordinates of each animated /// 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 /// 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 /// 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 /// positioned on that stack. If the [Hero] isn't axis aligned, this is going to
...@@ -95,56 +76,43 @@ abstract class _HeroHandle { ...@@ -95,56 +76,43 @@ abstract class _HeroHandle {
class Hero extends StatefulWidget { class Hero extends StatefulWidget {
/// Create a hero. /// Create a hero.
/// ///
/// The [tag] and [child] are required. /// The [tag] and [child] parameters must not be null.
Hero({ Hero({
Key key, Key key,
@required this.tag, @required this.tag,
this.turns: 1,
this.alwaysAnimate: false,
@required this.child, @required this.child,
}) : super(key: key) { }) : super(key: key) {
assert(tag != null); assert(tag != null);
assert(turns != null);
assert(alwaysAnimate != null);
assert(child != null); assert(child != null);
} }
/// The identifier for this particular hero. If the tag of this hero matches /// 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 /// the tag of a hero on a [PageRoute] that we're navigating to or from, then
/// animation will be triggered. /// a hero animation will be triggered.
final Object tag; 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 /// The appearance of this subtree should be similar to the appearance of
/// heroes in the application with the same [tag]. /// 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; final Widget child;
/// Return a hero tag to _HeroState map of all of the heroes within the given subtree. // Returns a map of all of the heroes in context, indexed by hero tag.
static Map<Object, _HeroHandle> _of(BuildContext context) { static Map<Object, _HeroState> _allHeroesFor(BuildContext context) {
final Map<Object, _HeroHandle> result = <Object, _HeroHandle>{}; assert(context != null);
final Map<Object, _HeroState> result = <Object, _HeroState>{};
void visitor(Element element) { void visitor(Element element) {
if (element.widget is Hero) { if (element.widget is Hero) {
StatefulElement hero = element; final StatefulElement hero = element;
Hero heroWidget = element.widget; final Hero heroWidget = element.widget;
Object tag = heroWidget.tag; final Object tag = heroWidget.tag;
assert(tag != null); assert(tag != null);
assert(() { assert(() {
if (result.containsKey(tag)) { if (result.containsKey(tag)) {
new FlutterError( throw new FlutterError(
'There are multiple heroes that share the same tag within a subtree.\n' '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), ' 'Within each subtree for which heroes are to be animated (typically a PageRoute subtree), '
'each Hero must have a unique non-null tag.\n' 'each Hero must have a unique non-null tag.\n'
...@@ -166,299 +134,270 @@ class Hero extends StatefulWidget { ...@@ -166,299 +134,270 @@ class Hero extends StatefulWidget {
_HeroState createState() => new _HeroState(); _HeroState createState() => new _HeroState();
} }
class _HeroState extends State<Hero> implements _HeroHandle { class _HeroState extends State<Hero> {
GlobalKey _key = new GlobalKey(); GlobalKey _key = new GlobalKey();
Size _placeholderSize; Size _placeholderSize;
VoidCallback _disposeCallback;
@override void startFlight() {
bool get alwaysAnimate => config.alwaysAnimate;
@override
_HeroManifest _takeChild(Animation<double> 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);
assert(mounted); assert(mounted);
final RenderBox box = context.findRenderObject();
assert(box != null && box.hasSize);
setState(() { setState(() {
_key = value; _placeholderSize = box.size;
_placeholderSize = null;
}); });
} }
void _resetChild() { void endFlight() {
if (mounted) if (mounted) {
_setChild(new GlobalKey()); setState(() {
} _placeholderSize = null;
});
@override }
void dispose() {
if (_disposeCallback != null)
_disposeCallback();
super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_placeholderSize != null) { if (_placeholderSize != null) {
assert(_key == null); return new SizedBox(
return new SizedBox(width: _placeholderSize.width, height: _placeholderSize.height); width: _placeholderSize.width,
height: _placeholderSize.height
);
} }
return new KeyedSubtree( return new KeyedSubtree(
key: _key, key: _key,
child: config.child child: config.child,
); );
} }
} }
// Everything known about a hero flight that's to be started or restarted.
class _HeroQuestState implements _HeroHandle { class _HeroFlightManifest {
_HeroQuestState({ _HeroFlightManifest({
this.tag, @required this.type,
this.key, @required this.overlay,
this.child, @required this.navigatorRect,
this.sourceStates, @required this.fromRoute,
this.animationArea, @required this.toRoute,
this.targetRect, @required this.fromHero,
this.targetTurns, @required this.toHero,
this.targetState, @required this.createRectTween,
this.currentRect,
this.currentTurns
}) { }) {
assert(tag != null); assert(fromHero.config.tag == toHero.config.tag);
for (_HeroState state in sourceStates)
state._disposeCallback = () => sourceStates.remove(state);
if (targetState != null)
targetState._disposeCallback = _handleTargetStateDispose;
} }
final Object tag; final _HeroFlightType type;
final GlobalKey key; final OverlayState overlay;
final Widget child; final Rect navigatorRect;
final Set<_HeroState> sourceStates; final PageRoute<dynamic> fromRoute;
final Rect animationArea; final PageRoute<dynamic> toRoute;
Rect targetRect; final _HeroState fromHero;
int targetTurns; final _HeroState toHero;
_HeroState targetState; final CreateRectTween createRectTween;
final RectTween currentRect;
final Tween<double> currentTurns;
OverlayEntry overlayEntry;
@override
bool get alwaysAnimate => true;
bool get taken => _taken; Object get tag => fromHero.config.tag;
bool _taken = false;
@override Animation<double> get animation {
_HeroManifest _takeChild(Animation<double> currentAnimation) { return new CurvedAnimation(
assert(!taken); parent: (type == _HeroFlightType.push) ? toRoute.animation : fromRoute.animation,
_taken = true; curve: Curves.fastOutSlowIn,
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() { @override
targetState = null; String toString() {
targetTurns = 0; return '_HeroFlightManifest($type hero: $tag from: ${fromRoute.settings} to: ${toRoute.settings})';
targetRect = targetRect.center & Size.zero;
WidgetsBinding.instance.addPostFrameCallback((Duration d) => overlayEntry.markNeedsBuild());
} }
}
Widget build(BuildContext context, Animation<double> animation) { // Builds the in-flight hero widget.
return new RelativePositionedTransition( class _HeroFlight {
rect: currentRect.animate(animation), _HeroFlight(this.onFlightEnded) {
size: animationArea.size, _proxyAnimation = new ProxyAnimation()..addStatusListener(_handleAnimationUpdate);
child: new RotationTransition(
turns: currentTurns.animate(animation),
child: new IgnorePointer(
child: new RepaintBoundary(
key: key,
child: child
)
)
)
);
} }
@mustCallSuper final _OnFlightEnded onFlightEnded;
void dispose() {
overlayEntry = null;
for (_HeroState state in sourceStates)
state._disposeCallback = null;
if (targetState != null)
targetState._disposeCallback = null;
}
}
class _HeroMatch { RectTween heroRect;
const _HeroMatch(this.from, this.to, this.tag); Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
final _HeroHandle from; ProxyAnimation _proxyAnimation;
final _HeroHandle to; _HeroFlightManifest manifest;
final Object tag; OverlayEntry overlayEntry;
}
/// Signature for a function that takes two [Rect] instances and returns a RectTween _doCreateRectTween(Rect begin, Rect end) {
/// [RectTween] that transitions between them. if (manifest.createRectTween != null)
/// return manifest.createRectTween(begin, end);
/// This is typically used with a [HeroController] to provide an animation for return new RectTween(begin: begin, end: end);
/// [Hero] positions that looks nicer than a linear movement. For example, see }
/// [MaterialRectArcTween].
typedef RectTween CreateRectTween(Rect begin, Rect end);
class _HeroParty { // The OverlayEntry WidgetBuilder callback for the hero's overlay.
_HeroParty({ this.onQuestFinished, this.createRectTween }); 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<double>(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,
),
),
),
);
},
);
}
final VoidCallback onQuestFinished; void _handleAnimationUpdate(AnimationStatus status) {
final CreateRectTween createRectTween; if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
_proxyAnimation.parent = null;
List<_HeroQuestState> _heroes = <_HeroQuestState>[]; assert(overlayEntry != null);
bool get isEmpty => _heroes.isEmpty; overlayEntry.remove();
overlayEntry = null;
Map<Object, _HeroHandle> getHeroesToAnimate() { manifest.fromHero.endFlight();
Map<Object, _HeroHandle> result = new Map<Object, _HeroHandle>(); manifest.toHero.endFlight();
for (_HeroQuestState hero in _heroes) onFlightEnded(this);
result[hero.tag] = hero; }
assert(!result.containsKey(null));
return result;
} }
RectTween _doCreateRectTween(Rect begin, Rect end) { // The simple case: we're either starting a push or a pop animation.
if (createRectTween != null) void start(_HeroFlightManifest initialManifest) {
return createRectTween(begin, end); assert(() {
return new RectTween(begin: begin, end: end); final Animation<double> 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;
}
});
Tween<double> createTurnsTween(double begin, double end) { manifest = initialManifest;
assert(end.floor() == end); if (manifest.type == _HeroFlightType.pop)
return new Tween<double>(begin: begin, end: end); _proxyAnimation.parent = new ReverseAnimation(manifest.animation);
} else
_proxyAnimation.parent = manifest.animation;
void animate(Map<Object, _HeroHandle> heroesFrom, Map<Object, _HeroHandle> heroesTo, Rect animationArea) { manifest.fromHero.startFlight();
assert(!heroesFrom.containsKey(null)); manifest.toHero.startFlight();
assert(!heroesTo.containsKey(null));
// make a list of pairs of heroes, based on the from and to lists
Map<Object, _HeroMatch> heroes = <Object, _HeroMatch>{};
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);
}
// create a heroating hero out of each pair heroRect = new RectTween(
final List<_HeroQuestState> _newHeroes = <_HeroQuestState>[]; begin: _globalRect(manifest.fromHero.context),
for (_HeroMatch heroPair in heroes.values) { end: _globalRect(manifest.toHero.context),
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)); overlayEntry = new OverlayEntry(builder: _buildOverlay);
for (_HeroQuestState hero in _heroes) manifest.overlay.insert(overlayEntry);
hero.dispose();
_heroes = _newHeroes;
} }
Animation<double> _currentAnimation; // 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);
void _clearCurrentAnimation() { if (manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.pop) {
_currentAnimation?.removeStatusListener(_handleUpdate); // A push flight was interrupted by a pop.
_currentAnimation = null; 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);
void setAnimation(Animation<double> animation) { _proxyAnimation.parent = new ReverseAnimation(newManifest.animation);
assert(animation != null || _heroes.isEmpty);
if (animation != _currentAnimation) {
_clearCurrentAnimation();
_currentAnimation = animation;
_currentAnimation?.addStatusListener(_handleUpdate);
}
}
void _handleUpdate(AnimationStatus status) { heroRect = new RectTween(
if (status == AnimationStatus.completed || begin: heroRect.end,
status == AnimationStatus.dismissed) { end: heroRect.begin,
for (_HeroQuestState hero in _heroes) { );
if (hero.targetState != null) } else if (manifest.type == _HeroFlightType.pop && newManifest.type == _HeroFlightType.push) {
hero.targetState._setChild(hero.key); // A pop flight was interrupted by a push.
for (_HeroState source in hero.sourceStates) assert(newManifest.animation.status == AnimationStatus.forward);
source._resetChild(); assert(manifest.toHero == newManifest.fromHero);
hero.dispose(); assert(manifest.toRoute == newManifest.fromRoute);
_proxyAnimation.parent = new Tween<double>(
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,
);
} }
_heroes.clear(); } else {
_clearCurrentAnimation(); // A push or a pop flight is heading to a new route, i.e.
if (onQuestFinished != null) // manifest.type == _HeroFlightType.push && newManifest.type == _HeroFlightType.push ||
onQuestFinished(); // 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 @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. /// A [Navigator] observer that manages [Hero] transitions.
...@@ -468,59 +407,34 @@ class _HeroParty { ...@@ -468,59 +407,34 @@ class _HeroParty {
class HeroController extends NavigatorObserver { class HeroController extends NavigatorObserver {
/// Creates a hero controller with the given [RectTween] constructor if any. /// 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. /// [RectTween] is used.
HeroController({ CreateRectTween createRectTween }) { HeroController({ this.createRectTween });
_party = new _HeroParty(
onQuestFinished: _handleQuestFinished,
createRectTween: createRectTween
);
}
// The current party, if they're on a quest. final CreateRectTween createRectTween;
_HeroParty _party;
// The settings used to prepare the next quest. // Disable Hero animations while a user gesture is controlling the navigation.
// These members are only non-null between the didPush/didPop call and the bool _questsEnabled = true;
// corresponding _updateQuest call.
PageRoute<dynamic> _from;
PageRoute<dynamic> _to;
Animation<double> _animation;
final List<OverlayEntry> _overlayEntries = new List<OverlayEntry>(); // All of the heroes that are currently in the overlay and in motion.
// Indexed by the hero tag.
// TBD: final?
Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};
@override @override
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) { void didPush(Route<dynamic> to, Route<dynamic> from) {
assert(navigator != null); assert(navigator != null);
assert(route != null); assert(to != null);
if (_questsEnabled && route is PageRoute<dynamic>) { _maybeStartHeroTransition(from, to, _HeroFlightType.push);
assert(route.animation != null);
if (previousRoute is PageRoute<dynamic>) // could be null
_from = previousRoute;
_to = route;
_animation = route.animation;
_checkForHeroQuest();
}
} }
@override @override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) { void didPop(Route<dynamic> from, Route<dynamic> to) {
assert(navigator != null); assert(navigator != null);
assert(route != null); assert(from != null);
if (_questsEnabled && route is PageRoute<dynamic>) { _maybeStartHeroTransition(from, to, _HeroFlightType.pop);
assert(route.animation != null);
if (route.animation.status != AnimationStatus.dismissed && previousRoute is PageRoute<dynamic>) {
_from = route;
_to = previousRoute;
_animation = route.animation;
_checkForHeroQuest();
}
}
} }
// Disable Hero animations while a user gesture is controlling the navigation.
bool _questsEnabled = true;
@override @override
void didStartUserGesture() { void didStartUserGesture() {
_questsEnabled = false; _questsEnabled = false;
...@@ -531,89 +445,72 @@ class HeroController extends NavigatorObserver { ...@@ -531,89 +445,72 @@ class HeroController extends NavigatorObserver {
_questsEnabled = true; _questsEnabled = true;
} }
void _checkForHeroQuest() { // If we're transitioning between different page routes, start a hero transition
assert(_questsEnabled); // after the toRoute has been laid out with its animation's value at 1.0.
if (_from != null && _to != null && _from != _to) { void _maybeStartHeroTransition(Route<dynamic> fromRoute, Route<dynamic> toRoute, _HeroFlightType flightType) {
assert(_animation != null); if (_questsEnabled && toRoute != fromRoute && toRoute is PageRoute<dynamic> && fromRoute is PageRoute<dynamic>) {
_to.offstage = _to.animation.status != AnimationStatus.completed; final PageRoute<dynamic> from = fromRoute;
_questsEnabled = false; final PageRoute<dynamic> to = toRoute;
WidgetsBinding.instance.addPostFrameCallback(_updateQuest); final Animation<double> animation = (flightType == _HeroFlightType.push) ? to.animation : from.animation;
} else {
// this isn't a valid quest // A "user" gesture may have already completed the pop.
_clearPendingHeroQuest(); 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() { // Find the matching pairs of heros in from and to and either start or a new
_removeHeroesFromOverlay(); // hero flight, or restart an existing one.
} void _startHeroTransition(PageRoute<dynamic> from, PageRoute<dynamic> to, _HeroFlightType flightType) {
// The navigator or one of the routes subtrees was removed before this
Rect _getAnimationArea(BuildContext context) { // end-of-frame callback was called then don't actually start a transition.
RenderBox box = context.findRenderObject(); // TBD: need to generate tests for these cases
Point topLeft = box.localToGlobal(Point.origin); if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) {
Point bottomRight = box.localToGlobal(box.size.bottomRight(Point.origin)); to.offstage = false; // TBD: only do this if to.subtreeContext != null?
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();
return; return;
} }
assert(_from.subtreeContext != null);
assert(_to.subtreeContext != null);
Map<Object, _HeroHandle> heroesFrom = _party.isEmpty ?
Hero._of(_from.subtreeContext) : _party.getHeroesToAnimate();
Map<Object, _HeroHandle> heroesTo = Hero._of(_to.subtreeContext);
_to.offstage = false;
Animation<double> 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);
}
animation = new CurvedAnimation(parent: animation, curve: curve);
_party.animate(heroesFrom, heroesTo, _getAnimationArea(navigator.context));
_removeHeroesFromOverlay();
_party.setAnimation(animation);
for (_HeroQuestState hero in _party._heroes) { final Rect navigatorRect = _globalRect(navigator.context);
hero.overlayEntry = _addHeroToOverlay(
(BuildContext context) => hero.build(navigator.context, animation), // At this point the toHeroes may have been built and laid out for the first time.
hero.tag, final Map<Object, _HeroState> fromHeroes = Hero._allHeroesFor(from.subtreeContext);
navigator.overlay final Map<Object, _HeroState> 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);
}
} }
_clearPendingHeroQuest();
} }
void _clearPendingHeroQuest() { void _handleFlightEnded(_HeroFlight flight) {
_from = null; _flights.remove(flight.manifest.tag);
_to = null;
_animation = null;
_questsEnabled = true;
} }
} }
...@@ -357,4 +357,36 @@ void main() { ...@@ -357,4 +357,36 @@ void main() {
// TODO(ianh): once https://github.com/flutter/flutter/issues/5631 is fixed, remove this line: // TODO(ianh): once https://github.com/flutter/flutter/issues/5631 is fixed, remove this line:
await tester.pump(const Duration(hours: 1)); 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: <Widget>[
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<Null>(
pageBuilder: (BuildContext context, Animation<double> _, Animation<double> __) {
return new Text('fail');
},
));
},
);
},
),
],
),
),
));
await tester.tap(find.text('push'));
await tester.pump();
expect(tester.takeException(), isFlutterError);
});
} }
...@@ -148,6 +148,7 @@ void main() { ...@@ -148,6 +148,7 @@ void main() {
expect(find.text('X'), findsNothing); expect(find.text('X'), findsNothing);
expect(find.text('Y'), findsOneWidget); expect(find.text('Y'), findsOneWidget);
await tester.pump();
await tester.pump(); await tester.pump();
expect(find.text('X'), findsOneWidget); expect(find.text('X'), findsOneWidget);
expect(find.text('Y'), findsOneWidget); expect(find.text('Y'), findsOneWidget);
......
...@@ -132,6 +132,8 @@ void main() { ...@@ -132,6 +132,8 @@ void main() {
navigator.pop(); navigator.pop();
expect(state(), equals('E')); // transition 1<-2 is at 1.0, just reversed expect(state(), equals('E')); // transition 1<-2 is at 1.0, just reversed
await tester.pump(); await tester.pump();
await tester.pump();
expect(state(), equals('BDE')); // transition 1<-2 is at 1.0 expect(state(), equals('BDE')); // transition 1<-2 is at 1.0
await tester.pump(kFourTenthsOfTheTransitionDuration); await tester.pump(kFourTenthsOfTheTransitionDuration);
......
...@@ -113,6 +113,7 @@ void main() { ...@@ -113,6 +113,7 @@ void main() {
expect(Navigator.canPop(containerKey2.currentContext), isTrue); expect(Navigator.canPop(containerKey2.currentContext), isTrue);
Navigator.pop(containerKey2.currentContext); Navigator.pop(containerKey2.currentContext);
await tester.pump(); await tester.pump();
await tester.pump();
expect(find.text('Home'), isOnstage); expect(find.text('Home'), isOnstage);
expect(find.text('Settings'), isOnstage); expect(find.text('Settings'), isOnstage);
......
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