mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
New Heroes (#8112)
This commit is contained in:
parent
cd3fd475d8
commit
ef004ae97d
@ -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.
|
||||
/// 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<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*.
|
||||
/// 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<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;
|
||||
|
||||
bool get taken => _taken;
|
||||
bool _taken = false;
|
||||
|
||||
@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)
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTargetStateDispose() {
|
||||
targetState = null;
|
||||
targetTurns = 0;
|
||||
targetRect = targetRect.center & Size.zero;
|
||||
WidgetsBinding.instance.addPostFrameCallback((Duration d) => overlayEntry.markNeedsBuild());
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@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<dynamic> fromRoute;
|
||||
final PageRoute<dynamic> 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<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;
|
||||
Animation<double> 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<double> _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<double> createTurnsTween(double begin, double end) {
|
||||
assert(end.floor() == end);
|
||||
return new Tween<double>(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<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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void animate(Map<Object, _HeroHandle> heroesFrom, Map<Object, _HeroHandle> 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<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);
|
||||
}
|
||||
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<double> _currentAnimation;
|
||||
|
||||
void _clearCurrentAnimation() {
|
||||
_currentAnimation?.removeStatusListener(_handleUpdate);
|
||||
_currentAnimation = null;
|
||||
}
|
||||
|
||||
void setAnimation(Animation<double> 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<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;
|
||||
}
|
||||
_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<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,
|
||||
);
|
||||
}
|
||||
} 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<dynamic> _from;
|
||||
PageRoute<dynamic> _to;
|
||||
Animation<double> _animation;
|
||||
|
||||
final List<OverlayEntry> _overlayEntries = new List<OverlayEntry>();
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
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<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};
|
||||
|
||||
@override
|
||||
void didPush(Route<dynamic> to, Route<dynamic> from) {
|
||||
assert(navigator != null);
|
||||
assert(to != null);
|
||||
_maybeStartHeroTransition(from, to, _HeroFlightType.push);
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route<dynamic> from, Route<dynamic> 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<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;
|
||||
final Rect navigatorRect = _globalRect(navigator.context);
|
||||
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user