blob: d936656e90705d32965ed044b20f748df0924fe2 [file] [log] [blame]
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'basic.dart';
import 'binding.dart';
import 'framework.dart';
import 'navigator.dart';
import 'overlay.dart';
import 'pages.dart';
import 'transitions.dart';
/// 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 Tween<Rect> 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.
}
// The bounding box for context in global coordinates.
Rect _globalBoundingBoxFor(BuildContext context) {
final RenderBox box = context.findRenderObject();
assert(box != null && box.hasSize);
return MatrixUtils.transformRect(box.getTransformTo(null), Offset.zero & box.size);
}
/// A widget that marks its child as being a candidate for hero animations.
///
/// 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
/// 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 navigation occurs, its
/// flight animation will be redirected to its new destination.
///
/// Routes must not contain more than one [Hero] for each [tag].
///
/// ## Discussion
///
/// 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
/// 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
/// fail in a rather ugly fashion. Don't rotate your heroes!
///
/// To make the animations look good, it's critical that the widget tree for the
/// hero in both locations be essentially identical. The widget of the *target*
/// is used to do the transition: when going from route A to route B, route B's
/// hero's widget is placed over route A's hero's widget, and route A's hero is
/// hidden. Then the widget is animated to route B's hero's position, and then
/// the widget is inserted into route B. When going back from B to A, route A's
/// hero's widget is placed over where route B's hero's widget was, and then the
/// animation goes the other way.
class Hero extends StatefulWidget {
/// Create a hero.
///
/// The [tag] and [child] parameters must not be null.
const Hero({
Key key,
@required this.tag,
this.createRectTween,
@required this.child,
}) : assert(tag != null),
assert(child != null),
super(key: key);
/// The identifier for this particular hero. If the tag of this hero matches
/// 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;
/// Defines how the destination hero's bounds change as it flies from the starting
/// route to the destination route.
///
/// A hero flight begins with the destination hero's [child] aligned with the
/// starting hero's child. The [Tween<Rect>] returned by this callback is used
/// to compute the hero's bounds as the flight animation's value goes from 0.0
/// to 1.0.
///
/// If this property is null, the default, then the value of
/// [HeroController.createRectTween] is used. The [HeroController] created by
/// [MaterialApp] creates a [MaterialRectAreTween].
final CreateRectTween createRectTween;
/// The widget subtree that will "fly" from one route to another during a
/// [Navigator] push or pop transition.
///
/// 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.
///
/// {@macro flutter.widgets.child}
final Widget child;
// 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) {
final StatefulElement hero = element;
final Hero heroWidget = element.widget;
final Object tag = heroWidget.tag;
assert(tag != null);
assert(() {
if (result.containsKey(tag)) {
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'
'In this case, multiple heroes had the following tag: $tag\n'
'Here is the subtree for one of the offending heroes:\n'
'${element.toStringDeep(prefixLineOne: "# ")}'
);
}
return true;
}());
final _HeroState heroState = hero.state;
result[tag] = heroState;
}
element.visitChildren(visitor);
}
context.visitChildElements(visitor);
return result;
}
@override
_HeroState createState() => new _HeroState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(new DiagnosticsProperty<Object>('tag', tag));
}
}
class _HeroState extends State<Hero> {
final GlobalKey _key = new GlobalKey();
Size _placeholderSize;
void startFlight() {
assert(mounted);
final RenderBox box = context.findRenderObject();
assert(box != null && box.hasSize);
setState(() {
_placeholderSize = box.size;
});
}
void endFlight() {
if (mounted) {
setState(() {
_placeholderSize = null;
});
}
}
@override
Widget build(BuildContext context) {
if (_placeholderSize != null) {
return new SizedBox(
width: _placeholderSize.width,
height: _placeholderSize.height
);
}
return new KeyedSubtree(
key: _key,
child: widget.child,
);
}
}
// Everything known about a hero flight that's to be started or diverted.
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(fromHero.widget.tag == toHero.widget.tag);
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;
Object get tag => fromHero.widget.tag;
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;
Tween<Rect> heroRect;
Animation<double> _heroOpacity = kAlwaysCompleteAnimation;
ProxyAnimation _proxyAnimation;
_HeroFlightManifest manifest;
OverlayEntry overlayEntry;
bool _aborted = false;
Tween<Rect> _doCreateRectTween(Rect begin, Rect end) {
final CreateRectTween createRectTween = manifest.toHero.widget.createRectTween ?? manifest.createRectTween;
if (createRectTween != null)
return createRectTween(begin, end);
return new RectTween(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.widget,
builder: (BuildContext context, Widget child) {
final RenderBox toHeroBox = manifest.toHero.context?.findRenderObject();
if (_aborted || toHeroBox == null || !toHeroBox.attached) {
// The toHero no longer exists or it's no longer the flight's destination.
// Continue flying while fading out.
if (_heroOpacity.isCompleted) {
_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 then recreate the heroRect tween.
final RenderBox finalRouteBox = manifest.toRoute.subtreeContext?.findRenderObject();
final Offset toHeroOrigin = toHeroBox.localToGlobal(Offset.zero, ancestor: finalRouteBox);
if (toHeroOrigin != heroRect.end.topLeft) {
final Rect heroRectEnd = toHeroOrigin & 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 _handleAnimationUpdate(AnimationStatus status) {
if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) {
_proxyAnimation.parent = null;
assert(overlayEntry != null);
overlayEntry.remove();
overlayEntry = null;
manifest.fromHero.endFlight();
manifest.toHero.endFlight();
onFlightEnded(this);
}
}
// The simple case: we're either starting a push or a pop animation.
void start(_HeroFlightManifest initialManifest) {
assert(!_aborted);
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;
}
}());
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 = _doCreateRectTween(
_globalBoundingBoxFor(manifest.fromHero.context),
_globalBoundingBoxFor(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 divert(_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);
// The same heroRect tween is used in reverse, rather than creating
// a new heroRect with _doCreateRectTween(heroRect.end, heroRect.begin).
// That's because tweens like MaterialRectArcTween may create a different
// path for swapped begin and end parameters. We want the pop flight
// path to be the same (in reverse) as the push flight path.
_proxyAnimation.parent = new ReverseAnimation(newManifest.animation);
heroRect = new ReverseTween<Rect>(heroRect);
} 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 = _doCreateRectTween(heroRect.end, _globalBoundingBoxFor(newManifest.toHero.context));
} else {
// TODO(hansmuller): Use ReverseTween here per github.com/flutter/flutter/pull/12203.
heroRect = _doCreateRectTween(heroRect.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 = _doCreateRectTween(heroRect.evaluate(_proxyAnimation), _globalBoundingBoxFor(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();
}
_aborted = false;
manifest = newManifest;
}
void abort() {
_aborted = true;
}
@override
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.
///
/// An instance of [HeroController] should be used in [Navigator.observers].
/// This is done automatically by [MaterialApp].
class HeroController extends NavigatorObserver {
/// Creates a hero controller with the given [RectTween] constructor if any.
///
/// The [createRectTween] argument is optional. If null, the controller uses a
/// linear [Tween<Rect>].
HeroController({ this.createRectTween });
/// Used to create [RectTween]s that interpolate the position of heros in flight.
///
/// If null, the controller uses a linear [RectTween].
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.
final Map<Object, _HeroFlight> _flights = <Object, _HeroFlight>{};
@override
void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
assert(navigator != null);
assert(route != null);
_maybeStartHeroTransition(previousRoute, route, _HeroFlightType.push);
}
@override
void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
assert(navigator != null);
assert(route != null);
_maybeStartHeroTransition(route, previousRoute, _HeroFlightType.pop);
}
@override
void didStartUserGesture() {
_questsEnabled = false;
}
@override
void didStopUserGesture() {
_questsEnabled = true;
}
// 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 `to` route are
// going to end up, and the `to` route will go back onstage.
to.offstage = to.animation.value == 0.0;
WidgetsBinding.instance.addPostFrameCallback((Duration value) {
_startHeroTransition(from, to, flightType);
});
}
}
// Find the matching pairs of heros in from and to and either start or a new
// hero flight, or divert an existing one.
void _startHeroTransition(PageRoute<dynamic> from, PageRoute<dynamic> to, _HeroFlightType flightType) {
// If 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.
if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) {
to.offstage = false; // in case we set this in _maybeStartHeroTransition
return;
}
final Rect navigatorRect = _globalBoundingBoxFor(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].divert(manifest);
else
_flights[tag] = new _HeroFlight(_handleFlightEnded)..start(manifest);
} else if (_flights[tag] != null) {
_flights[tag].abort();
}
}
}
void _handleFlightEnded(_HeroFlight flight) {
_flights.remove(flight.manifest.tag);
}
}