// 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 'dart:collection'; import 'package:meta/meta.dart'; 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'; // 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; } abstract class _HeroHandle { bool get alwaysAnimate; _HeroManifest _takeChild(Animation<double> currentAnimation); } /// 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. /// /// Hero animations are managed by a [HeroController]. /// /// 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]. /// /// ## 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 /// 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] are required. 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. 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. /// /// This subtree should match the appearance of the subtrees of any other /// heroes in the application with the same [tag]. 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>{}; void visitor(Element element) { if (element.widget is Hero) { StatefulElement hero = element; Hero heroWidget = element.widget; Object tag = heroWidget.tag; assert(tag != null); assert(() { if (result.containsKey(tag)) { 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 tag "$tag".' ); } return true; }); _HeroState heroState = hero.state; result[tag] = heroState; } element.visitChildren(visitor); } context.visitChildElements(visitor); return result; } @override _HeroState createState() => new _HeroState(); } class _HeroState extends State<Hero> implements _HeroHandle { 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.needsLayout); assert(renderObject.hasSize); if (_placeholderSize == null) { // We are a "from" hero, about to depart on a quest. // Remember our size so that we can leave a placeholder. _placeholderSize = renderObject.size; } final Point heroTopLeft = renderObject.localToGlobal(Point.origin); final Point heroBottomRight = renderObject.localToGlobal(renderObject.size.bottomRight(Point.origin)); final Rect heroArea = new Rect.fromLTRB(heroTopLeft.x, heroTopLeft.y, heroBottomRight.x, heroBottomRight.y); _HeroManifest result = new _HeroManifest( key: _key, // might be null, e.g. if the hero is returning to us config: config, sourceStates: new HashSet<_HeroState>.from(<_HeroState>[this]), currentRect: heroArea, currentTurns: config.turns.toDouble() ); if (_key != null) setState(() { _key = null; }); return result; } void _setChild(GlobalKey value) { assert(_key == null); assert(_placeholderSize != null); assert(value != null); assert(mounted); setState(() { _key = value; _placeholderSize = null; }); } void _resetChild() { if (mounted) _setChild(new GlobalKey()); } @override void dispose() { if (_disposeCallback != null) _disposeCallback(); super.dispose(); } @override Widget build(BuildContext context) { if (_placeholderSize != null) { assert(_key == null); return new SizedBox(width: _placeholderSize.width, height: _placeholderSize.height); } return new KeyedSubtree( key: _key, 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 }) { assert(tag != null); for (_HeroState state in sourceStates) state._disposeCallback = () => sourceStates.remove(state); if (targetState != null) targetState._disposeCallback = _handleTargetStateDispose; } 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 CreateRectTween createRectTween; List<_HeroQuestState> _heroes = <_HeroQuestState>[]; bool get isEmpty => _heroes.isEmpty; 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; } RectTween _doCreateRectTween(Rect begin, Rect end) { if (createRectTween != null) return 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); } 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); } // 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.length == 0); if (animation != _currentAnimation) { _clearCurrentAnimation(); _currentAnimation = animation; _currentAnimation?.addStatusListener(_handleUpdate); } } 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(); } _heroes.clear(); _clearCurrentAnimation(); if (onQuestFinished != null) onQuestFinished(); } } @override String toString() => '$_heroes'; } /// A [Navigator] observer that manages [Hero] transitions. /// /// An instance of [HeroController] should be used as the [Navigator.observer]. /// 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. By default, a linear /// [RectTween] is used. HeroController({ CreateRectTween createRectTween }) { _party = new _HeroParty( onQuestFinished: _handleQuestFinished, createRectTween: 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 (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 (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(); } } } // Disable Hero animations while a user gesture is controlling the navigation. bool _questsEnabled = true; @override void didStartUserGesture() { _questsEnabled = false; } @override void didStopUserGesture() { _questsEnabled = true; } void _checkForHeroQuest() { if (_from != null && _to != null && _from != _to && _questsEnabled) { assert(_animation != null); _to.offstage = _to.animation.status != AnimationStatus.completed; WidgetsBinding.instance.addPostFrameCallback(_updateQuest); } else { // this isn't a valid quest _clearPendingHeroQuest(); } } 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) { if (navigator == null) { // The navigator was removed before this end-of-frame callback was called. _clearPendingHeroQuest(); 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 ); } _clearPendingHeroQuest(); } void _clearPendingHeroQuest() { _from = null; _to = null; _animation = null; } }