From fa8c45151f9de9ddbd7e9dc21250001bed4e3b09 Mon Sep 17 00:00:00 2001 From: Hixie Date: Fri, 2 Oct 2015 15:07:35 -0700 Subject: [PATCH] Heroes --- examples/stocks/lib/stock_home.dart | 8 +- examples/stocks/lib/stock_row.dart | 17 +- examples/stocks/lib/stock_symbol_viewer.dart | 9 +- packages/flutter/lib/src/widgets/heroes.dart | 389 ++++++++++++++++++ .../flutter/lib/src/widgets/navigator.dart | 222 +++++++++- packages/flutter/lib/widgets.dart | 1 + 6 files changed, 615 insertions(+), 31 deletions(-) create mode 100644 packages/flutter/lib/src/widgets/heroes.dart diff --git a/examples/stocks/lib/stock_home.dart b/examples/stocks/lib/stock_home.dart index 45dd149e53a..e8b143ef39b 100644 --- a/examples/stocks/lib/stock_home.dart +++ b/examples/stocks/lib/stock_home.dart @@ -186,14 +186,16 @@ class StockHomeState extends State { Widget buildStockList(BuildContext context, Iterable stocks) { return new StockList( stocks: stocks.toList(), - onAction: (Stock stock, GlobalKey arrowKey) { + onAction: (Stock stock, Key arrowKey) { setState(() { stock.percentChange = 100.0 * (1.0 / stock.lastSale); stock.lastSale += 1.0; }); }, - onOpen: (Stock stock, GlobalKey arrowKey) { - config.navigator.pushNamed('/stock/${stock.symbol}'); + onOpen: (Stock stock, Key arrowKey) { + Set mostValuableKeys = new Set(); + mostValuableKeys.add(arrowKey); + config.navigator.pushNamed('/stock/${stock.symbol}', mostValuableKeys: mostValuableKeys); } ); } diff --git a/examples/stocks/lib/stock_row.dart b/examples/stocks/lib/stock_row.dart index c57c119d7ba..5f2010ce8d0 100644 --- a/examples/stocks/lib/stock_row.dart +++ b/examples/stocks/lib/stock_row.dart @@ -6,7 +6,7 @@ part of stocks; enum StockRowPartKind { arrow } -class StockRowPartKey extends GlobalKey { +class StockRowPartKey extends Key { const StockRowPartKey(this.stock, this.part) : super.constructor(); final Stock stock; final StockRowPartKind part; @@ -21,7 +21,7 @@ class StockRowPartKey extends GlobalKey { String toString() => '[StockRowPartKey ${stock.symbol}:${part.toString().split(".")[1]})]'; } -typedef void StockRowActionCallback(Stock stock, GlobalKey arrowKey); +typedef void StockRowActionCallback(Stock stock, Key arrowKey); class StockRow extends StatelessComponent { StockRow({ @@ -30,7 +30,7 @@ class StockRow extends StatelessComponent { this.onLongPressed }) : this.stock = stock, _arrowKey = new StockRowPartKey(stock, StockRowPartKind.arrow), - super(key: new GlobalObjectKey(stock)); + super(key: new ObjectKey(stock)); final Stock stock; final StockRowActionCallback onPressed; @@ -53,7 +53,7 @@ class StockRow extends StatelessComponent { } Widget build(BuildContext context) { - String lastSale = "\$${stock.lastSale.toStringAsFixed(2)}"; + final String lastSale = "\$${stock.lastSale.toStringAsFixed(2)}"; String changeInPrice = "${stock.percentChange.toStringAsFixed(2)}%"; if (stock.percentChange > 0) changeInPrice = "+" + changeInPrice; @@ -69,9 +69,12 @@ class StockRow extends StatelessComponent { ), child: new Row([ new Container( - key: _arrowKey, - child: new StockArrow(percentChange: stock.percentChange), - margin: const EdgeDims.only(right: 5.0) + margin: const EdgeDims.only(right: 5.0), + child: new Hero( + tag: StockRowPartKind.arrow, + key: _arrowKey, + child: new StockArrow(percentChange: stock.percentChange) + ) ), new Flexible( child: new Row([ diff --git a/examples/stocks/lib/stock_symbol_viewer.dart b/examples/stocks/lib/stock_symbol_viewer.dart index c5e06a28957..bef0c27f529 100644 --- a/examples/stocks/lib/stock_symbol_viewer.dart +++ b/examples/stocks/lib/stock_symbol_viewer.dart @@ -36,7 +36,11 @@ class StockSymbolViewer extends StatelessComponent { '${stock.symbol}', style: Theme.of(context).text.display2 ), - new StockArrow(percentChange: stock.percentChange) + new Hero( + tag: StockRowPartKind.arrow, + turns: 2, + child: new StockArrow(percentChange: stock.percentChange) + ), ], justifyContent: FlexJustifyContent.spaceBetween ), @@ -51,7 +55,8 @@ class StockSymbolViewer extends StatelessComponent { ) ) ) - ]) + ] + ) ); } diff --git a/packages/flutter/lib/src/widgets/heroes.dart b/packages/flutter/lib/src/widgets/heroes.dart new file mode 100644 index 00000000000..cb0465c7129 --- /dev/null +++ b/packages/flutter/lib/src/widgets/heroes.dart @@ -0,0 +1,389 @@ +// 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/animation.dart'; +import 'package:flutter/rendering.dart'; + +import 'basic.dart'; +import 'framework.dart'; +import 'navigator.dart'; +import 'transitions.dart'; + +// Heroes are the parts of an application's screen-to-screen transitions where a +// component 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 navigator Route. + +// 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. Tag must either be unique within the +// current route's widget subtree, or all the Heroes with that tag on a +// particular route must have a key. When the app transitions from one route to +// another, each tag present is animated. When there's exactly one hero with +// that tag, that hero will be animated for that tag. When there are multiple +// heroes in a route with the same tag, then whichever hero has a key that +// matches one of the keys in the "most important key" list given to the +// navigator when the route was pushed will be animated. If a hero is only +// present on one of the routes and not the other, then it will be made to +// appear or disappear as needed. + +// 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. + +// Heroes and the Navigator'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 the +// Navigator 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. + +// 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/engine/issues/1698 + +final Object centerOfAttentionHeroTag = new Object(); + +class _HeroManifest { + const _HeroManifest({ + this.key, + this.config, + this.sourceStates, + this.currentRect, + this.currentTurns + }); + final GlobalKey key; + final Widget config; + final Set sourceStates; + final RelativeRect currentRect; + final double currentTurns; +} + +abstract class HeroHandle { + _HeroManifest _takeChild(Rect animationArea); +} + +class Hero extends StatefulComponent { + Hero({ + Key key, + this.navigator, + this.tag, + this.child, + this.turns: 1 + }) : super(key: key) { + assert(tag != null); + } + + final NavigatorState navigator; + final Object tag; + final Widget child; + final int turns; + + static Map of(BuildContext context, Set mostValuableKeys) { + mostValuableKeys ??= new Set(); + assert(!mostValuableKeys.contains(null)); + // first we collect ALL the heroes, sorted by their tags + Map> heroes = >{}; + void visitor(Element element) { + if (element.widget is Hero) { + StatefulComponentElement hero = element; + Object tag = hero.widget.tag; + assert(tag != null); + Key key = hero.widget.key; + final Map tagHeroes = heroes.putIfAbsent(tag, () => {}); + assert(!tagHeroes.containsKey(key)); + tagHeroes[key] = hero.state; + } + element.visitChildren(visitor); + } + context.visitChildElements(visitor); + // next, for each tag, we're going to decide on the one hero we care about for that tag + Map result = {}; + for (Object tag in heroes.keys) { + assert(tag != null); + if (heroes[tag].length == 1) { + result[tag] = heroes[tag].values.first; + } else { + assert(heroes[tag].length > 1); + assert(!heroes[tag].containsKey(null)); + assert(heroes[tag].keys.where((Key key) => mostValuableKeys.contains(key)).length <= 1); + Key mostValuableKey = mostValuableKeys.firstWhere((Key key) => heroes[tag].containsKey(key), orElse: () => null); + if (mostValuableKey != null) + result[tag] = heroes[tag][mostValuableKey]; + } + } + assert(!result.containsKey(null)); + return result; + } + + HeroState createState() => new HeroState(); +} + +enum _HeroMode { constructing, initialized, measured, taken } + +class HeroState extends State implements HeroHandle { + + void initState() { + assert(_mode == _HeroMode.constructing); + super.initState(); + _key = new GlobalKey(); + _mode = _HeroMode.initialized; + } + + GlobalKey _key; + + _HeroMode _mode = _HeroMode.constructing; + Size _size; + + _HeroManifest _takeChild(Rect animationArea) { + assert(_mode == _HeroMode.measured || _mode == _HeroMode.taken); + final RenderBox renderObject = context.findRenderObject(); + 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); + final RelativeRect startRect = new RelativeRect.fromRect(heroArea, animationArea); + _HeroManifest result = new _HeroManifest( + key: _key, + config: config, + sourceStates: new Set.from([this]), + currentRect: startRect, + currentTurns: config.turns.toDouble() + ); + setState(() { + _key = null; + _mode = _HeroMode.taken; + }); + return result; + } + + void _setChild(GlobalKey value) { + assert(_mode == _HeroMode.taken); + assert(_key == null); + assert(_size != null); + if (mounted) + setState(() { _key = value; }); + _size = null; + _mode = _HeroMode.initialized; + } + + void _resetChild() { + assert(_mode == _HeroMode.taken); + assert(_key == null); + assert(_size != null); + if (mounted) + setState(() { _key = new GlobalKey(); }); + _size = null; + _mode = _HeroMode.initialized; + } + + Widget build(BuildContext context) { + switch (_mode) { + case _HeroMode.constructing: + assert(false); + return null; + case _HeroMode.initialized: + case _HeroMode.measured: + return new SizeObserver( + onSizeChanged: (Size size) { + assert(_mode == _HeroMode.initialized || _mode == _HeroMode.measured); + _size = size; + _mode = _HeroMode.measured; + }, + child: new KeyedSubtree( + key: _key, + child: config.child + ) + ); + case _HeroMode.taken: + return new SizedBox(width: _size.width, height: _size.height); + } + } + +} + + +class _HeroQuestState implements HeroHandle { + _HeroQuestState({ + this.tag, + this.key, + this.child, + this.sourceStates, + this.targetRect, + this.targetTurns, + this.targetState, + this.currentRect, + this.currentTurns + }) { + assert(tag != null); + } + + final Object tag; + final GlobalKey key; + final Widget child; + final Set sourceStates; + final RelativeRect targetRect; + final int targetTurns; + final HeroState targetState; + final AnimatedRelativeRectValue currentRect; + final AnimatedValue currentTurns; + + bool get taken => _taken; + bool _taken = false; + _HeroManifest _takeChild(Rect animationArea) { + assert(!taken); + _taken = true; + Set states = sourceStates; + if (targetState != null) + states = states.union(new Set.from([targetState])); + return new _HeroManifest( + key: key, + config: child, + sourceStates: states, + currentRect: currentRect.value, + currentTurns: currentTurns.value + ); + } + + Widget build(BuildContext context, PerformanceView performance) { + return new PositionedTransition( + rect: currentRect, + performance: performance, + child: new RotationTransition( + turns: currentTurns, + performance: performance, + child: new KeyedSubtree( + key: key, + child: child + ) + ) + ); + } +} + +class _HeroMatch { + const _HeroMatch(this.from, this.to, this.tag); + final HeroHandle from; + final HeroHandle to; + final Object tag; +} + +typedef void QuestFinishedHandler(); + +class HeroParty { + HeroParty({ this.onQuestFinished }); + + final QuestFinishedHandler onQuestFinished; + + List<_HeroQuestState> _heroes = <_HeroQuestState>[]; + bool get isEmpty => _heroes.isEmpty; + + Map getHeroesToAnimate() { + Map result = new Map(); + for (_HeroQuestState hero in _heroes) + result[hero.tag] = hero; + assert(!result.containsKey(null)); + return result; + } + + AnimatedRelativeRectValue createAnimatedRelativeRect(RelativeRect begin, RelativeRect end, Curve curve) { + return new AnimatedRelativeRectValue(begin, end: end, curve: curve); + } + + AnimatedValue createAnimatedTurns(double begin, double end, Curve curve) { + assert(end.floor() == end); + return new AnimatedValue(begin, end: end, curve: curve); + } + + void animate(Map heroesFrom, Map heroesTo, Rect animationArea, Curve curve) { + assert(!heroesFrom.containsKey(null)); + assert(!heroesTo.containsKey(null)); + + // make a list of pairs of heroes, based on the from and to lists + Map heroes = {}; + 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); + _HeroManifest from = heroPair.from?._takeChild(animationArea); + assert(heroPair.to == null || heroPair.to is HeroState); + _HeroManifest to = heroPair.to?._takeChild(animationArea); + 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 sourceStates = from != null ? from.sourceStates : new Set(); + sourceStates.remove(targetState); + RelativeRect sourceRect = from != null ? from.currentRect : + new RelativeRect.fromRect(to.currentRect.toRect(animationArea).center & Size.zero, animationArea); + RelativeRect targetRect = to != null ? to.currentRect : + new RelativeRect.fromRect(from.currentRect.toRect(animationArea).center & Size.zero, animationArea); + double sourceTurns = from != null ? from.currentTurns : 0.0; + double targetTurns = to != null ? to.currentTurns : 0.0; + _newHeroes.add(new _HeroQuestState( + tag: heroPair.tag, + key: from != null ? from.key : to.key, + child: to != null ? to.config : from.config, + sourceStates: sourceStates, + targetRect: targetRect, + targetTurns: targetTurns.floor(), + targetState: targetState, + currentRect: createAnimatedRelativeRect(sourceRect, targetRect, curve), + currentTurns: createAnimatedTurns(sourceTurns, targetTurns, curve) + )); + } + + assert(!_heroes.any((_HeroQuestState hero) => !hero.taken)); + _heroes = _newHeroes; + } + + PerformanceView _currentPerformance; + + Iterable getWidgets(BuildContext context, PerformanceView performance) sync* { + assert(performance != null || _heroes.length == 0); + if (performance != _currentPerformance) { + if (_currentPerformance != null) + _currentPerformance.removeStatusListener(_handleUpdate); + _currentPerformance = performance; + if (_currentPerformance != null) + _currentPerformance.addStatusListener(_handleUpdate); + } + for (_HeroQuestState hero in _heroes) + yield hero.build(context, performance); + } + + void _handleUpdate(PerformanceStatus status) { + if (status == PerformanceStatus.completed || + status == PerformanceStatus.dismissed) { + for (_HeroQuestState hero in _heroes) { + if (hero.targetState != null) + hero.targetState._setChild(hero.key); + for (HeroState source in hero.sourceStates) + source._resetChild(); + if (onQuestFinished != null) + onQuestFinished(); + } + _heroes.clear(); + _currentPerformance = null; + } + } + + String toString() => '$_heroes'; +} diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index 5dbb6966fdd..df59effe94e 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart'; import 'basic.dart'; import 'focus.dart'; import 'framework.dart'; +import 'heroes.dart'; import 'transitions.dart'; import 'gridpaper.dart'; @@ -51,6 +52,40 @@ class Navigator extends StatefulComponent { // The navigator tracks which "page" we are on. // It also animates between these pages. +// Pages can have "heroes", which are UI elements that animate from point to point. +// These animations are called journeys. +// +// Journeys can start in two conditions: +// - Everything is calm, and we have no heroes in flight. In this case, we will +// have to collect the heroes from the route we're starting at and the route +// we're going to, and try to transition from one set to the other. +// - We already have heroes in flight. In that case, we just want to look at +// the heroes of our destination, and then try to transition to them from the +// in-flight heroes. + +class _HeroTransitionInstruction { + Route from; + Route to; + void update(Route newFrom, Route newTo) { + assert(newFrom != null); + assert(newTo != null); + if (!newFrom.canHaveHeroes || !newTo.canHaveHeroes) + return; + assert(newFrom.performance != null); + assert(newTo.performance != null); + if (from == null) + from = newFrom; + to = newTo; + if (from == to) + reset(); + } + void reset() { + assert(hasInstructions); + from = null; + to = null; + } + bool get hasInstructions => from != null || to != null; +} class NavigatorState extends State { @@ -62,6 +97,7 @@ class NavigatorState extends State { void initState() { super.initState(); + _activeHeroes = new HeroParty(onQuestFinished: _handleHeroQuestFinished); PageRoute route = new PageRoute(config.routes[kDefaultRouteName], name: kDefaultRouteName); assert(route.hasContent); assert(!route.ephemeral); @@ -76,14 +112,22 @@ class NavigatorState extends State { )); } - void pushNamed(String name) { + void pushNamed(String name, { Set mostValuableKeys }) { RouteBuilder generateRoute() { assert(config.onGenerateRoute != null); return config.onGenerateRoute(name); } final RouteBuilder builder = config.routes[name] ?? generateRoute() ?? config.onUnknownRoute; assert(builder != null); // 404 getting your 404! - push(new PageRoute(builder, name: name)); + push(new PageRoute(builder, name: name, mostValuableKeys: mostValuableKeys)); + } + + final _HeroTransitionInstruction _desiredHeroes = new _HeroTransitionInstruction(); + HeroParty _activeHeroes; + + void _handleHeroQuestFinished() { + for (Route route in _history) + route._hasActiveHeroes = false; } void push(Route route) { @@ -94,6 +138,14 @@ class NavigatorState extends State { currentRoute.didPop(null); _currentPosition -= 1; } + // find the most recent active route that might have heroes + if (route.hasContent) { + int index = _currentPosition; + while (index > 0 && !_history[index].hasContent) + index -= 1; + assert(_history[index].hasContent); + _desiredHeroes.update(_history[index], route); + } // add the new route _currentPosition += 1; _insertRoute(route); @@ -118,6 +170,14 @@ class NavigatorState extends State { void pop([dynamic result]) { setState(() { assert(_currentPosition > 0); + // find the most recent previous route that might have heroes + if (currentRoute.hasContent) { + int index = _currentPosition - 1; + while (index > 0 && !_history[index].hasContent) + index -= 1; + assert(_history[index].hasContent); + _desiredHeroes.update(currentRoute, _history[index]); + } // pop the route currentRoute.didPop(result); _currentPosition -= 1; @@ -153,11 +213,17 @@ class NavigatorState extends State { void _removeRoute(Route route) { assert(_history.contains(route)); setState(() { + if (_desiredHeroes.hasInstructions) { + if (_desiredHeroes.from == route || _desiredHeroes.to == route) + _desiredHeroes.reset(); + } _history.remove(route); }); } - Widget build(BuildContext context) { + PerformanceView _currentHeroPerformance; + + Widget build(BuildContext context) { List visibleRoutes = []; assert(() { @@ -166,22 +232,35 @@ class NavigatorState extends State { return true; }); + + bool alreadyInsertedHeroes = false; bool alreadyInsertedModalBarrier = false; Route nextContentRoute; + PerformanceView nextHeroPerformance; for (int i = _history.length-1; i >= 0; i -= 1) { - Route route = _history[i]; + final Route route = _history[i]; if (!route.hasContent) { assert(!route.modal); + assert(!_desiredHeroes.hasInstructions || (_desiredHeroes.from != route && _desiredHeroes.to != route)); + assert(!route._hasActiveHeroes); continue; } - visibleRoutes.add( - new KeyedSubtree( - key: new ObjectKey(route), - child: route._internalBuild(nextContentRoute) - ) - ); - if (route.isActuallyOpaque) + if (route._hasActiveHeroes && !alreadyInsertedHeroes) { + visibleRoutes.addAll(_activeHeroes.getWidgets(context, _currentHeroPerformance)); + alreadyInsertedHeroes = true; + } + if (_desiredHeroes.hasInstructions) { + if ((_desiredHeroes.to == route || _desiredHeroes.from == route) && nextHeroPerformance == null) + nextHeroPerformance = route.performance; + visibleRoutes.add(route._internalBuild(nextContentRoute, buildTargetHeroes: _desiredHeroes.to == route)); + } else { + visibleRoutes.add(route._internalBuild(nextContentRoute)); + } + if (route.isActuallyOpaque) { + assert(!_desiredHeroes.hasInstructions || + (_history.indexOf(_desiredHeroes.from) >= i && _history.indexOf(_desiredHeroes.to) >= i)); break; + } assert(route.modal || route.ephemeral); if (route.modal && i > 0 && !alreadyInsertedModalBarrier) { visibleRoutes.add(new Listener( @@ -192,12 +271,54 @@ class NavigatorState extends State { } nextContentRoute = route; } + + if (_desiredHeroes.hasInstructions) { + assert(nextHeroPerformance != null); + scheduler.requestPostFrameCallback((Duration timestamp) { + Map heroesFrom; + Map heroesTo; + Set mostValuableKeys = new Set(); + if (_desiredHeroes.from.mostValuableKeys != null) + mostValuableKeys.addAll(_desiredHeroes.from.mostValuableKeys); + if (_desiredHeroes.to.mostValuableKeys != null) + mostValuableKeys.addAll(_desiredHeroes.to.mostValuableKeys); + if (_activeHeroes.isEmpty) { + assert(!_desiredHeroes.from._hasActiveHeroes); + heroesFrom = _desiredHeroes.from.getHeroesToAnimate(mostValuableKeys); + _desiredHeroes.from._hasActiveHeroes = heroesFrom.length > 0; + } else { + assert(_desiredHeroes.from._hasActiveHeroes); + heroesFrom = _activeHeroes.getHeroesToAnimate(); + } + heroesTo = _desiredHeroes.to.getHeroesToAnimate(mostValuableKeys); + _desiredHeroes.to._hasActiveHeroes = heroesTo.length > 0; + _desiredHeroes.reset(); + setState(() { + final RenderBox renderObject = context.findRenderObject(); + final Point animationTopLeft = renderObject.localToGlobal(Point.origin); + final Point animationBottomRight = renderObject.localToGlobal(renderObject.size.bottomRight(Point.origin)); + final Rect animationArea = new Rect.fromLTRB(animationTopLeft.x, animationTopLeft.y, animationBottomRight.x, animationBottomRight.y); + Curve curve = Curves.ease; + if (nextHeroPerformance.status == PerformanceStatus.reverse) { + nextHeroPerformance = new ReversePerformance(nextHeroPerformance); + curve = new Interval(nextHeroPerformance.progress, 1.0, curve: curve); + } + _activeHeroes.animate(heroesFrom, heroesTo, animationArea, curve); + _currentHeroPerformance = nextHeroPerformance; + }); + }); + } + return new Focus(child: new Stack(visibleRoutes.reversed.toList())); } } abstract class Route { + Route() { + _subtreeKey = new GlobalKey(label: debugLabel); + } + /// If hasContent is true, then the route represents some on-screen state. /// /// If hasContent is false, then no performance will be created, and the values of @@ -284,21 +405,56 @@ abstract class Route { /// Called by the navigator.build() function if hasContent is true, to get the /// subtree for this route. - Widget _internalBuild(Route nextRoute) { + /// + /// If buildTargetHeroes is true, then getHeroesToAnimate() will be called + /// after this build, before the next build, and this build should render the + /// route off-screen, at the end of its animation. Next frame, the argument + /// will be false, and the tree should be built at the first frame of the + /// transition animation, whatever that is. + Widget _internalBuild(Route nextRoute, { bool buildTargetHeroes: false }) { assert(navigator != null); - return build(new RouteArguments( + return keySubtree(build(new RouteArguments( navigator, previousPerformance: performance, nextPerformance: nextRoute?.performance - )); + ))); } + bool get canHaveHeroes => hasContent && modal && opaque; + Set get mostValuableKeys => null; + + /// Return a party of heroes (one per tag) to animate. This is called by the + /// navigator when hasContent is true just after this route, the previous + /// route, or the next route, has been pushed or popped, to figure out which + /// heroes it should be trying to animate. + Map getHeroesToAnimate([Set mostValuableKeys]) => const {}; + bool _hasActiveHeroes = false; + + /// Returns the BuildContext for the root of the subtree built for this route, + /// assuming that internalBuild used keySubtree to build that subtree. + /// This is only valid after a build phase. + BuildContext get context => _subtreeKey.currentContext; + + GlobalKey _subtreeKey; + + /// Wraps the given subtree in a route-specific GlobalKey. + Widget keySubtree(Widget child) { + return new KeyedSubtree( + key: _subtreeKey, + child: child + ); + } + + /// Called by internalBuild. This is the method to override if you want to + /// change what subtree is built for this route. Widget build(RouteArguments args); String get debugLabel => '$runtimeType'; - String toString() => '$runtimeType(performance: $performance)'; + + String toString() => '$runtimeType(performance: $performance; key: $_subtreeKey)'; } + abstract class PerformanceRoute extends Route { PerformanceRoute() { _performance = createPerformance(); @@ -315,6 +471,22 @@ abstract class PerformanceRoute extends Route { Duration get transitionDuration; + Widget _internalBuild(Route nextRoute, { bool buildTargetHeroes: false }) { + assert(hasContent); + assert(transitionDuration > Duration.ZERO); + if (buildTargetHeroes && performance.progress != 1.0) { + Performance fakePerformance = createPerformance(); + assert(fakePerformance != null); + fakePerformance.progress = 1.0; + return new OffStage( + child: keySubtree( + build(new RouteArguments(navigator, previousPerformance: fakePerformance)) + ) + ); + } + return super._internalBuild(nextRoute, buildTargetHeroes: buildTargetHeroes); + } + void didPush(NavigatorState navigator) { super.didPush(navigator); _performance?.forward(); @@ -330,27 +502,39 @@ const Duration _kTransitionDuration = const Duration(milliseconds: 150); const Point _kTransitionStartPoint = const Point(0.0, 75.0); /// A route that represents a page in an application. +/// +/// PageRoutes try to animate between themselves in a fashion that is aware of +/// any Heroes. class PageRoute extends PerformanceRoute { PageRoute(this._builder, { - this.name: '' - }) { + this.name: '', + Set mostValuableKeys + }) : _mostValuableKeys = mostValuableKeys { assert(_builder != null); } final RouteBuilder _builder; final String name; + final Set _mostValuableKeys; + + Set get mostValuableKeys => _mostValuableKeys; bool get opaque => true; Duration get transitionDuration => _kTransitionDuration; + Map getHeroesToAnimate([Set mostValuableKeys]) { + return Hero.of(context, mostValuableKeys); + } + Widget build(RouteArguments args) { // TODO(jackson): Hit testing should ignore transform // TODO(jackson): Block input unless content is interactive + // TODO(ianh): Support having different transitions, e.g. when heroes are around. return new SlideTransition( - performance: performance, + performance: args.previousPerformance, position: new AnimatedValue(_kTransitionStartPoint, end: Point.origin, curve: Curves.easeOut), child: new FadeTransition( - performance: performance, + performance: args.previousPerformance, opacity: new AnimatedValue(0.0, end: 1.0, curve: Curves.easeOut), child: invokeBuilder(args) ) diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 53a4b1c31b3..a18ebef3d7a 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -16,6 +16,7 @@ export 'src/widgets/focus.dart'; export 'src/widgets/framework.dart'; export 'src/widgets/gesture_detector.dart'; export 'src/widgets/gridpaper.dart'; +export 'src/widgets/heroes.dart'; export 'src/widgets/homogeneous_viewport.dart'; export 'src/widgets/mimic.dart'; export 'src/widgets/mixed_viewport.dart';