mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Heroes
This commit is contained in:
parent
8414b4c1a1
commit
fa8c45151f
@ -186,14 +186,16 @@ class StockHomeState extends State<StockHome> {
|
|||||||
Widget buildStockList(BuildContext context, Iterable<Stock> stocks) {
|
Widget buildStockList(BuildContext context, Iterable<Stock> stocks) {
|
||||||
return new StockList(
|
return new StockList(
|
||||||
stocks: stocks.toList(),
|
stocks: stocks.toList(),
|
||||||
onAction: (Stock stock, GlobalKey arrowKey) {
|
onAction: (Stock stock, Key arrowKey) {
|
||||||
setState(() {
|
setState(() {
|
||||||
stock.percentChange = 100.0 * (1.0 / stock.lastSale);
|
stock.percentChange = 100.0 * (1.0 / stock.lastSale);
|
||||||
stock.lastSale += 1.0;
|
stock.lastSale += 1.0;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onOpen: (Stock stock, GlobalKey arrowKey) {
|
onOpen: (Stock stock, Key arrowKey) {
|
||||||
config.navigator.pushNamed('/stock/${stock.symbol}');
|
Set<Key> mostValuableKeys = new Set<Key>();
|
||||||
|
mostValuableKeys.add(arrowKey);
|
||||||
|
config.navigator.pushNamed('/stock/${stock.symbol}', mostValuableKeys: mostValuableKeys);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ part of stocks;
|
|||||||
|
|
||||||
enum StockRowPartKind { arrow }
|
enum StockRowPartKind { arrow }
|
||||||
|
|
||||||
class StockRowPartKey extends GlobalKey {
|
class StockRowPartKey extends Key {
|
||||||
const StockRowPartKey(this.stock, this.part) : super.constructor();
|
const StockRowPartKey(this.stock, this.part) : super.constructor();
|
||||||
final Stock stock;
|
final Stock stock;
|
||||||
final StockRowPartKind part;
|
final StockRowPartKind part;
|
||||||
@ -21,7 +21,7 @@ class StockRowPartKey extends GlobalKey {
|
|||||||
String toString() => '[StockRowPartKey ${stock.symbol}:${part.toString().split(".")[1]})]';
|
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 {
|
class StockRow extends StatelessComponent {
|
||||||
StockRow({
|
StockRow({
|
||||||
@ -30,7 +30,7 @@ class StockRow extends StatelessComponent {
|
|||||||
this.onLongPressed
|
this.onLongPressed
|
||||||
}) : this.stock = stock,
|
}) : this.stock = stock,
|
||||||
_arrowKey = new StockRowPartKey(stock, StockRowPartKind.arrow),
|
_arrowKey = new StockRowPartKey(stock, StockRowPartKind.arrow),
|
||||||
super(key: new GlobalObjectKey(stock));
|
super(key: new ObjectKey(stock));
|
||||||
|
|
||||||
final Stock stock;
|
final Stock stock;
|
||||||
final StockRowActionCallback onPressed;
|
final StockRowActionCallback onPressed;
|
||||||
@ -53,7 +53,7 @@ class StockRow extends StatelessComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
String lastSale = "\$${stock.lastSale.toStringAsFixed(2)}";
|
final String lastSale = "\$${stock.lastSale.toStringAsFixed(2)}";
|
||||||
String changeInPrice = "${stock.percentChange.toStringAsFixed(2)}%";
|
String changeInPrice = "${stock.percentChange.toStringAsFixed(2)}%";
|
||||||
if (stock.percentChange > 0)
|
if (stock.percentChange > 0)
|
||||||
changeInPrice = "+" + changeInPrice;
|
changeInPrice = "+" + changeInPrice;
|
||||||
@ -69,9 +69,12 @@ class StockRow extends StatelessComponent {
|
|||||||
),
|
),
|
||||||
child: new Row(<Widget>[
|
child: new Row(<Widget>[
|
||||||
new Container(
|
new Container(
|
||||||
|
margin: const EdgeDims.only(right: 5.0),
|
||||||
|
child: new Hero(
|
||||||
|
tag: StockRowPartKind.arrow,
|
||||||
key: _arrowKey,
|
key: _arrowKey,
|
||||||
child: new StockArrow(percentChange: stock.percentChange),
|
child: new StockArrow(percentChange: stock.percentChange)
|
||||||
margin: const EdgeDims.only(right: 5.0)
|
)
|
||||||
),
|
),
|
||||||
new Flexible(
|
new Flexible(
|
||||||
child: new Row(<Widget>[
|
child: new Row(<Widget>[
|
||||||
|
@ -36,7 +36,11 @@ class StockSymbolViewer extends StatelessComponent {
|
|||||||
'${stock.symbol}',
|
'${stock.symbol}',
|
||||||
style: Theme.of(context).text.display2
|
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
|
justifyContent: FlexJustifyContent.spaceBetween
|
||||||
),
|
),
|
||||||
@ -51,7 +55,8 @@ class StockSymbolViewer extends StatelessComponent {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
])
|
]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
389
packages/flutter/lib/src/widgets/heroes.dart
Normal file
389
packages/flutter/lib/src/widgets/heroes.dart
Normal file
@ -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<HeroState> 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<Object, HeroHandle> of(BuildContext context, Set<Key> mostValuableKeys) {
|
||||||
|
mostValuableKeys ??= new Set<Key>();
|
||||||
|
assert(!mostValuableKeys.contains(null));
|
||||||
|
// first we collect ALL the heroes, sorted by their tags
|
||||||
|
Map<Object, Map<Key, HeroState>> heroes = <Object, Map<Key, HeroState>>{};
|
||||||
|
void visitor(Element element) {
|
||||||
|
if (element.widget is Hero) {
|
||||||
|
StatefulComponentElement<Hero, HeroState> hero = element;
|
||||||
|
Object tag = hero.widget.tag;
|
||||||
|
assert(tag != null);
|
||||||
|
Key key = hero.widget.key;
|
||||||
|
final Map<Key, HeroState> tagHeroes = heroes.putIfAbsent(tag, () => <Key, HeroState>{});
|
||||||
|
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<Object, HeroHandle> result = <Object, HeroHandle>{};
|
||||||
|
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<Hero> 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<HeroState>.from(<HeroState>[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<HeroState> sourceStates;
|
||||||
|
final RelativeRect targetRect;
|
||||||
|
final int targetTurns;
|
||||||
|
final HeroState targetState;
|
||||||
|
final AnimatedRelativeRectValue currentRect;
|
||||||
|
final AnimatedValue<double> currentTurns;
|
||||||
|
|
||||||
|
bool get taken => _taken;
|
||||||
|
bool _taken = false;
|
||||||
|
_HeroManifest _takeChild(Rect animationArea) {
|
||||||
|
assert(!taken);
|
||||||
|
_taken = true;
|
||||||
|
Set<HeroState> states = sourceStates;
|
||||||
|
if (targetState != null)
|
||||||
|
states = states.union(new Set<HeroState>.from(<HeroState>[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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedRelativeRectValue createAnimatedRelativeRect(RelativeRect begin, RelativeRect end, Curve curve) {
|
||||||
|
return new AnimatedRelativeRectValue(begin, end: end, curve: curve);
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedValue<double> createAnimatedTurns(double begin, double end, Curve curve) {
|
||||||
|
assert(end.floor() == end);
|
||||||
|
return new AnimatedValue<double>(begin, end: end, curve: curve);
|
||||||
|
}
|
||||||
|
|
||||||
|
void animate(Map<Object, HeroHandle> heroesFrom, Map<Object, HeroHandle> 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<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);
|
||||||
|
_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<HeroState> sourceStates = from != null ? from.sourceStates : new Set<HeroState>();
|
||||||
|
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<Widget> 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';
|
||||||
|
}
|
@ -8,6 +8,7 @@ import 'package:flutter/rendering.dart';
|
|||||||
import 'basic.dart';
|
import 'basic.dart';
|
||||||
import 'focus.dart';
|
import 'focus.dart';
|
||||||
import 'framework.dart';
|
import 'framework.dart';
|
||||||
|
import 'heroes.dart';
|
||||||
import 'transitions.dart';
|
import 'transitions.dart';
|
||||||
import 'gridpaper.dart';
|
import 'gridpaper.dart';
|
||||||
|
|
||||||
@ -51,6 +52,40 @@ class Navigator extends StatefulComponent {
|
|||||||
|
|
||||||
// The navigator tracks which "page" we are on.
|
// The navigator tracks which "page" we are on.
|
||||||
// It also animates between these pages.
|
// 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<Navigator> {
|
class NavigatorState extends State<Navigator> {
|
||||||
|
|
||||||
@ -62,6 +97,7 @@ class NavigatorState extends State<Navigator> {
|
|||||||
|
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_activeHeroes = new HeroParty(onQuestFinished: _handleHeroQuestFinished);
|
||||||
PageRoute route = new PageRoute(config.routes[kDefaultRouteName], name: kDefaultRouteName);
|
PageRoute route = new PageRoute(config.routes[kDefaultRouteName], name: kDefaultRouteName);
|
||||||
assert(route.hasContent);
|
assert(route.hasContent);
|
||||||
assert(!route.ephemeral);
|
assert(!route.ephemeral);
|
||||||
@ -76,14 +112,22 @@ class NavigatorState extends State<Navigator> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void pushNamed(String name) {
|
void pushNamed(String name, { Set<Key> mostValuableKeys }) {
|
||||||
RouteBuilder generateRoute() {
|
RouteBuilder generateRoute() {
|
||||||
assert(config.onGenerateRoute != null);
|
assert(config.onGenerateRoute != null);
|
||||||
return config.onGenerateRoute(name);
|
return config.onGenerateRoute(name);
|
||||||
}
|
}
|
||||||
final RouteBuilder builder = config.routes[name] ?? generateRoute() ?? config.onUnknownRoute;
|
final RouteBuilder builder = config.routes[name] ?? generateRoute() ?? config.onUnknownRoute;
|
||||||
assert(builder != null); // 404 getting your 404!
|
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) {
|
void push(Route route) {
|
||||||
@ -94,6 +138,14 @@ class NavigatorState extends State<Navigator> {
|
|||||||
currentRoute.didPop(null);
|
currentRoute.didPop(null);
|
||||||
_currentPosition -= 1;
|
_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
|
// add the new route
|
||||||
_currentPosition += 1;
|
_currentPosition += 1;
|
||||||
_insertRoute(route);
|
_insertRoute(route);
|
||||||
@ -118,6 +170,14 @@ class NavigatorState extends State<Navigator> {
|
|||||||
void pop([dynamic result]) {
|
void pop([dynamic result]) {
|
||||||
setState(() {
|
setState(() {
|
||||||
assert(_currentPosition > 0);
|
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
|
// pop the route
|
||||||
currentRoute.didPop(result);
|
currentRoute.didPop(result);
|
||||||
_currentPosition -= 1;
|
_currentPosition -= 1;
|
||||||
@ -153,10 +213,16 @@ class NavigatorState extends State<Navigator> {
|
|||||||
void _removeRoute(Route route) {
|
void _removeRoute(Route route) {
|
||||||
assert(_history.contains(route));
|
assert(_history.contains(route));
|
||||||
setState(() {
|
setState(() {
|
||||||
|
if (_desiredHeroes.hasInstructions) {
|
||||||
|
if (_desiredHeroes.from == route || _desiredHeroes.to == route)
|
||||||
|
_desiredHeroes.reset();
|
||||||
|
}
|
||||||
_history.remove(route);
|
_history.remove(route);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PerformanceView _currentHeroPerformance;
|
||||||
|
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
List<Widget> visibleRoutes = <Widget>[];
|
List<Widget> visibleRoutes = <Widget>[];
|
||||||
|
|
||||||
@ -166,22 +232,35 @@ class NavigatorState extends State<Navigator> {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
bool alreadyInsertedHeroes = false;
|
||||||
bool alreadyInsertedModalBarrier = false;
|
bool alreadyInsertedModalBarrier = false;
|
||||||
Route nextContentRoute;
|
Route nextContentRoute;
|
||||||
|
PerformanceView nextHeroPerformance;
|
||||||
for (int i = _history.length-1; i >= 0; i -= 1) {
|
for (int i = _history.length-1; i >= 0; i -= 1) {
|
||||||
Route route = _history[i];
|
final Route route = _history[i];
|
||||||
if (!route.hasContent) {
|
if (!route.hasContent) {
|
||||||
assert(!route.modal);
|
assert(!route.modal);
|
||||||
|
assert(!_desiredHeroes.hasInstructions || (_desiredHeroes.from != route && _desiredHeroes.to != route));
|
||||||
|
assert(!route._hasActiveHeroes);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
visibleRoutes.add(
|
if (route._hasActiveHeroes && !alreadyInsertedHeroes) {
|
||||||
new KeyedSubtree(
|
visibleRoutes.addAll(_activeHeroes.getWidgets(context, _currentHeroPerformance));
|
||||||
key: new ObjectKey(route),
|
alreadyInsertedHeroes = true;
|
||||||
child: route._internalBuild(nextContentRoute)
|
}
|
||||||
)
|
if (_desiredHeroes.hasInstructions) {
|
||||||
);
|
if ((_desiredHeroes.to == route || _desiredHeroes.from == route) && nextHeroPerformance == null)
|
||||||
if (route.isActuallyOpaque)
|
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;
|
break;
|
||||||
|
}
|
||||||
assert(route.modal || route.ephemeral);
|
assert(route.modal || route.ephemeral);
|
||||||
if (route.modal && i > 0 && !alreadyInsertedModalBarrier) {
|
if (route.modal && i > 0 && !alreadyInsertedModalBarrier) {
|
||||||
visibleRoutes.add(new Listener(
|
visibleRoutes.add(new Listener(
|
||||||
@ -192,12 +271,54 @@ class NavigatorState extends State<Navigator> {
|
|||||||
}
|
}
|
||||||
nextContentRoute = route;
|
nextContentRoute = route;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_desiredHeroes.hasInstructions) {
|
||||||
|
assert(nextHeroPerformance != null);
|
||||||
|
scheduler.requestPostFrameCallback((Duration timestamp) {
|
||||||
|
Map<Object, HeroHandle> heroesFrom;
|
||||||
|
Map<Object, HeroHandle> heroesTo;
|
||||||
|
Set<Key> mostValuableKeys = new Set<Key>();
|
||||||
|
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()));
|
return new Focus(child: new Stack(visibleRoutes.reversed.toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class Route {
|
abstract class Route {
|
||||||
|
Route() {
|
||||||
|
_subtreeKey = new GlobalKey(label: debugLabel);
|
||||||
|
}
|
||||||
|
|
||||||
/// If hasContent is true, then the route represents some on-screen state.
|
/// 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
|
/// 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
|
/// Called by the navigator.build() function if hasContent is true, to get the
|
||||||
/// subtree for this route.
|
/// 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);
|
assert(navigator != null);
|
||||||
return build(new RouteArguments(
|
return keySubtree(build(new RouteArguments(
|
||||||
navigator,
|
navigator,
|
||||||
previousPerformance: performance,
|
previousPerformance: performance,
|
||||||
nextPerformance: nextRoute?.performance
|
nextPerformance: nextRoute?.performance
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get canHaveHeroes => hasContent && modal && opaque;
|
||||||
|
Set<Key> 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<Object, HeroHandle> getHeroesToAnimate([Set<Key> mostValuableKeys]) => const <Object, HeroHandle>{};
|
||||||
|
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);
|
Widget build(RouteArguments args);
|
||||||
|
|
||||||
String get debugLabel => '$runtimeType';
|
String get debugLabel => '$runtimeType';
|
||||||
String toString() => '$runtimeType(performance: $performance)';
|
|
||||||
|
String toString() => '$runtimeType(performance: $performance; key: $_subtreeKey)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
abstract class PerformanceRoute extends Route {
|
abstract class PerformanceRoute extends Route {
|
||||||
PerformanceRoute() {
|
PerformanceRoute() {
|
||||||
_performance = createPerformance();
|
_performance = createPerformance();
|
||||||
@ -315,6 +471,22 @@ abstract class PerformanceRoute extends Route {
|
|||||||
|
|
||||||
Duration get transitionDuration;
|
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) {
|
void didPush(NavigatorState navigator) {
|
||||||
super.didPush(navigator);
|
super.didPush(navigator);
|
||||||
_performance?.forward();
|
_performance?.forward();
|
||||||
@ -330,27 +502,39 @@ const Duration _kTransitionDuration = const Duration(milliseconds: 150);
|
|||||||
const Point _kTransitionStartPoint = const Point(0.0, 75.0);
|
const Point _kTransitionStartPoint = const Point(0.0, 75.0);
|
||||||
|
|
||||||
/// A route that represents a page in an application.
|
/// 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 {
|
class PageRoute extends PerformanceRoute {
|
||||||
PageRoute(this._builder, {
|
PageRoute(this._builder, {
|
||||||
this.name: '<anonymous>'
|
this.name: '<anonymous>',
|
||||||
}) {
|
Set<Key> mostValuableKeys
|
||||||
|
}) : _mostValuableKeys = mostValuableKeys {
|
||||||
assert(_builder != null);
|
assert(_builder != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
final RouteBuilder _builder;
|
final RouteBuilder _builder;
|
||||||
final String name;
|
final String name;
|
||||||
|
final Set<Key> _mostValuableKeys;
|
||||||
|
|
||||||
|
Set<Key> get mostValuableKeys => _mostValuableKeys;
|
||||||
|
|
||||||
bool get opaque => true;
|
bool get opaque => true;
|
||||||
Duration get transitionDuration => _kTransitionDuration;
|
Duration get transitionDuration => _kTransitionDuration;
|
||||||
|
|
||||||
|
Map<Object, HeroHandle> getHeroesToAnimate([Set<Key> mostValuableKeys]) {
|
||||||
|
return Hero.of(context, mostValuableKeys);
|
||||||
|
}
|
||||||
|
|
||||||
Widget build(RouteArguments args) {
|
Widget build(RouteArguments args) {
|
||||||
// TODO(jackson): Hit testing should ignore transform
|
// TODO(jackson): Hit testing should ignore transform
|
||||||
// TODO(jackson): Block input unless content is interactive
|
// TODO(jackson): Block input unless content is interactive
|
||||||
|
// TODO(ianh): Support having different transitions, e.g. when heroes are around.
|
||||||
return new SlideTransition(
|
return new SlideTransition(
|
||||||
performance: performance,
|
performance: args.previousPerformance,
|
||||||
position: new AnimatedValue<Point>(_kTransitionStartPoint, end: Point.origin, curve: Curves.easeOut),
|
position: new AnimatedValue<Point>(_kTransitionStartPoint, end: Point.origin, curve: Curves.easeOut),
|
||||||
child: new FadeTransition(
|
child: new FadeTransition(
|
||||||
performance: performance,
|
performance: args.previousPerformance,
|
||||||
opacity: new AnimatedValue<double>(0.0, end: 1.0, curve: Curves.easeOut),
|
opacity: new AnimatedValue<double>(0.0, end: 1.0, curve: Curves.easeOut),
|
||||||
child: invokeBuilder(args)
|
child: invokeBuilder(args)
|
||||||
)
|
)
|
||||||
|
@ -16,6 +16,7 @@ export 'src/widgets/focus.dart';
|
|||||||
export 'src/widgets/framework.dart';
|
export 'src/widgets/framework.dart';
|
||||||
export 'src/widgets/gesture_detector.dart';
|
export 'src/widgets/gesture_detector.dart';
|
||||||
export 'src/widgets/gridpaper.dart';
|
export 'src/widgets/gridpaper.dart';
|
||||||
|
export 'src/widgets/heroes.dart';
|
||||||
export 'src/widgets/homogeneous_viewport.dart';
|
export 'src/widgets/homogeneous_viewport.dart';
|
||||||
export 'src/widgets/mimic.dart';
|
export 'src/widgets/mimic.dart';
|
||||||
export 'src/widgets/mixed_viewport.dart';
|
export 'src/widgets/mixed_viewport.dart';
|
||||||
|
Loading…
Reference in New Issue
Block a user