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 {
stock: stock,
arrow: new Hero(
tag: stock,
turns: 2,
child: new StockArrow(percentChange: stock.percentChange)
)
)
......
......@@ -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.
// 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
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;
/// 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);
typedef void _OnFlightEnded(_HeroFlight flight);
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<double> 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*.
///
/// 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.
/// 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.
///
/// 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
/// will continue to the next page.
/// If a [Hero] is already in flight when navigation occurs, its
/// 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
///
/// 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.
///
/// 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.
/// The widget subtree that will "fly" from one route to another during a
/// [Naviator] push or pop transition.
///
/// 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<Object, _HeroHandle> _of(BuildContext context) {
final Map<Object, _HeroHandle> result = <Object, _HeroHandle>{};
// Returns a map of all of the heroes in context, indexed by hero tag.
static Map<Object, _HeroState> _allHeroesFor(BuildContext context) {
assert(context != null);
final Map<Object, _HeroState> result = <Object, _HeroState>{};
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<Hero> implements _HeroHandle {
class _HeroState extends State<Hero> {
GlobalKey _key = new GlobalKey();
Size _placeholderSize;
VoidCallback _disposeCallback;
@override
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);
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<double> currentTurns;
OverlayEntry overlayEntry;
@override
bool get alwaysAnimate => true;
final _HeroFlightType type;
final OverlayState overlay;
final Rect navigatorRect;
final PageRoute<dynamic> fromRoute;
final PageRoute<dynamic> toRoute;
final _HeroState fromHero;
final _HeroState toHero;
final CreateRectTween createRectTween;
bool get taken => _taken;
bool _taken = false;
Object get tag => fromHero.config.tag;
@override
_HeroManifest _takeChild(Animation<double> 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)
Animation<double> get animation {
return new CurvedAnimation(
parent: (type == _HeroFlightType.push) ? toRoute.animation : fromRoute.animation,
curve: Curves.fastOutSlowIn,
);
}
void _handleTargetStateDispose() {
targetState = null;
targetTurns = 0;
targetRect = targetRect.center & Size.zero;
WidgetsBinding.instance.addPostFrameCallback((Duration d) => overlayEntry.markNeedsBuild());
@override
String toString() {
return '_HeroFlightManifest($type hero: $tag from: ${fromRoute.settings} to: ${toRoute.settings})';
}
}
Widget build(BuildContext context, Animation<double> 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
)
)
)
);
// Builds the in-flight hero widget.
class _HeroFlight {
_HeroFlight(this.onFlightEnded) {
_proxyAnimation = new ProxyAnimation()..addStatusListener(_handleAnimationUpdate);
}
@mustCallSuper
void dispose() {
overlayEntry = null;
for (_HeroState state in sourceStates)
state._disposeCallback = null;
if (targetState != null)
targetState._disposeCallback = null;
}
}
final _OnFlightEnded onFlightEnded;
class _HeroMatch {
const _HeroMatch(this.from, this.to, this.tag);
final _HeroHandle from;
final _HeroHandle to;
final Object tag;
}
RectTween heroRect;
Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
ProxyAnimation _proxyAnimation;
_HeroFlightManifest manifest;
OverlayEntry overlayEntry;
/// 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);
RectTween _doCreateRectTween(Rect begin, Rect end) {
if (manifest.createRectTween != null)
return manifest.createRectTween(begin, end);
return new RectTween(begin: begin, end: end);
}
class _HeroParty {
_HeroParty({ this.onQuestFinished, this.createRectTween });
// 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<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;
final CreateRectTween createRectTween;
void _handleAnimationUpdate(AnimationStatus status) {
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
_proxyAnimation.parent = null;
List<_HeroQuestState> _heroes = <_HeroQuestState>[];
bool get isEmpty => _heroes.isEmpty;
assert(overlayEntry != null);
overlayEntry.remove();
overlayEntry = null;
Map<Object, _HeroHandle> getHeroesToAnimate() {
Map<Object, _HeroHandle> result = new Map<Object, _HeroHandle>();
for (_HeroQuestState hero in _heroes)
result[hero.tag] = hero;
assert(!result.containsKey(null));
return result;
manifest.fromHero.endFlight();
manifest.toHero.endFlight();
onFlightEnded(this);
}
}
RectTween _doCreateRectTween(Rect begin, Rect end) {
if (createRectTween != null)
return createRectTween(begin, end);
return new RectTween(begin: begin, end: end);
}
// The simple case: we're either starting a push or a pop animation.
void start(_HeroFlightManifest initialManifest) {
assert(() {
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) {
assert(end.floor() == end);
return new Tween<double>(begin: begin, end: end);
}
manifest = initialManifest;
if (manifest.type == _HeroFlightType.pop)
_proxyAnimation.parent = new ReverseAnimation(manifest.animation);
else
_proxyAnimation.parent = manifest.animation;
void animate(Map<Object, _HeroHandle> heroesFrom, Map<Object, _HeroHandle> heroesTo, Rect animationArea) {
assert(!heroesFrom.containsKey(null));
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);
}
manifest.fromHero.startFlight();
manifest.toHero.startFlight();
// 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)
));
}
heroRect = new RectTween(
begin: _globalRect(manifest.fromHero.context),
end: _globalRect(manifest.toHero.context),
);
assert(!_heroes.any((_HeroQuestState hero) => !hero.taken));
for (_HeroQuestState hero in _heroes)
hero.dispose();
_heroes = _newHeroes;
overlayEntry = new OverlayEntry(builder: _buildOverlay);
manifest.overlay.insert(overlayEntry);
}
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() {
_currentAnimation?.removeStatusListener(_handleUpdate);
_currentAnimation = null;
}
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);
void setAnimation(Animation<double> animation) {
assert(animation != null || _heroes.isEmpty);
if (animation != _currentAnimation) {
_clearCurrentAnimation();
_currentAnimation = animation;
_currentAnimation?.addStatusListener(_handleUpdate);
}
}
_proxyAnimation.parent = new ReverseAnimation(newManifest.animation);
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();
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<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();
_clearCurrentAnimation();
if (onQuestFinished != null)
onQuestFinished();
} 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;
final CreateRectTween createRectTween;
// 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<dynamic> _from;
PageRoute<dynamic> _to;
Animation<double> _animation;
// Disable Hero animations while a user gesture is controlling the navigation.
bool _questsEnabled = true;
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
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
void didPush(Route<dynamic> to, Route<dynamic> from) {
assert(navigator != null);
assert(route != null);
if (_questsEnabled && route is PageRoute<dynamic>) {
assert(route.animation != null);
if (previousRoute is PageRoute<dynamic>) // could be null
_from = previousRoute;
_to = route;
_animation = route.animation;
_checkForHeroQuest();
}
assert(to != null);
_maybeStartHeroTransition(from, to, _HeroFlightType.push);
}
@override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
void didPop(Route<dynamic> from, Route<dynamic> to) {
assert(navigator != null);
assert(route != null);
if (_questsEnabled && route is PageRoute<dynamic>) {
assert(route.animation != null);
if (route.animation.status != AnimationStatus.dismissed && previousRoute is PageRoute<dynamic>) {
_from = route;
_to = previousRoute;
_animation = route.animation;
_checkForHeroQuest();
}
}
assert(from != null);
_maybeStartHeroTransition(from, to, _HeroFlightType.pop);
}
// Disable Hero animations while a user gesture is controlling the navigation.
bool _questsEnabled = true;
@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<dynamic> fromRoute, Route<dynamic> toRoute, _HeroFlightType flightType) {
if (_questsEnabled && toRoute != fromRoute && toRoute is PageRoute<dynamic> && fromRoute is PageRoute<dynamic>) {
final PageRoute<dynamic> from = fromRoute;
final PageRoute<dynamic> to = toRoute;
final Animation<double> 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<dynamic> from, PageRoute<dynamic> 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<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) {
hero.overlayEntry = _addHeroToOverlay(
(BuildContext context) => hero.build(navigator.context, animation),
hero.tag,
navigator.overlay
);
final Rect navigatorRect = _globalRect(navigator.context);
// At this point the toHeroes may have been built and laid out for the first time.
final Map<Object, _HeroState> fromHeroes = Hero._allHeroesFor(from.subtreeContext);
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() {
_from = null;
_to = null;
_animation = null;
_questsEnabled = true;
void _handleFlightEnded(_HeroFlight flight) {
_flights.remove(flight.manifest.tag);
}
}
......@@ -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: <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() {
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);
......
......@@ -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);
......
......@@ -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);
......
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