- 原文地址:Flutter Heroes and Villains — bringing balance to the Flutterverse.
- 原文做者:Norbert
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:DateBro
一个 Hero 经常与多个 Villain 相伴而生。前端
Villain 容许你只需几行代码就能够添加上面的页面转换。android
安装包在这里。你能够在项目的 README 如何使用 Villains。这篇文章更侧重于解释 Heroes 和 Villains 以及全部这些背后的思考过程。ios
Flutter 最令惊奇的一点是它为全部东西提供漂亮和干净的 API。我喜欢你使用 Hero 的方式。两行简单的代码,它就生效了。你只须要把 Hero 扔到这两个地方,按照标签分配,其它就不须要管了。git
先简单了解一下 Hero。github
咱们来快速了解一下 Hero 是如何实现的。后端
Hero 的动画涉及三个主要步骤。bash
1. 找到并匹配 Heroesapp
第一步是肯定哪些 Hero 存在以及哪些 Hero 具备相同的标记。ide
2. 肯定 Hero 位置函数
而后,捕获两个 Hero 的位置并准备好旅程。
3. 启动旅程
旅程始终在新屏幕上进行,而不在实际的组件中。在开始页面上的组件在旅程期间被替换成空的占位符组件 (SizedBox)
。而使用 Overlay
(Overlay
能够在全部内容上显示组件)。
整个 Hero 动画发生在正在打开的页面上。组件是彻底独立,不在页面之间共享任何状态的。
能够经过 NavigationObserver
观察压入和弹出路由的事件。
/// 一个管理 [Hero] 过渡的 [Navigator] observer。
///
/// 应该在 [Navigator.observers] 中使用 [HeroController] 的实例。
/// 这由 [MaterialApp] 自动完成。
class HeroController extends NavigatorObserver
复制代码
Hero 使用这个类开始旅程。除了可以本身添加 NavigationObservers
以外,MaterialApp
默认添加了 HeroController
。看一下这里。
/// 建立一个 Hero
///
/// [tag] 和 [child] 必须非空。
const Hero({
Key key,
@required this.tag,
this.createRectTween,
@required this.child,
}) : assert(tag != null),
assert(child != null),
super(key: key);
复制代码
Hero 的构造器
Hero 组件实际上并无作太多。它拥有 child 和 tag。除此以外,createRectTween
参数决定了 Hero
在飞往目的地时所采用的路由。默认的实现是 MaterialRectArcTween
。顾名思义,它将 Hero 沿弧线移动到最终位置。
Hero 的状态也负责捕获大小并用占位符替换本身。
_allHeroesFor
元素(具体组件)放在树中。经过访客,你能够沿着树下去并收集信息。
// 返回上下文中全部 Hero 的 map,由 hero 标记索引。
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;
}
复制代码
在方法内部声明了一个名为 visitor 的内联函数。context.visitChildElements(visitor)
方法和 element.visitChildren(vistor)
直到访问完上下文的全部元素才调用函数。在每次访问时,它会检查这个 child 是否为 Hero
,若是是,则将其保存到 map 中。
// 在 from 和 to 中找到匹配的 Hero 对,并启动新的 Hero 旅程,
// 或转移现有的 Hero 旅程。
void _startHeroTransition(PageRoute<dynamic> from, PageRoute<dynamic> to, _HeroFlightType flightType) {
// 若是在调用帧尾回调以前删除了导航器或其中一个路由子树,
// 那么接下来实际上不会开始转换。
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);
// 在这一点上,toHeroes 多是第一次建造和布局。
final Map<Object, _HeroState> fromHeroes = Hero._allHeroesFor(from.subtreeContext);
final Map<Object, _HeroState> toHeroes = Hero._allHeroesFor(to.subtreeContext);
// 若是 `to` 路由是在屏幕外的,
// 那么咱们暗中将其动画值恢复到它“移到”屏幕外以前的状态。
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();
}
}
}
复制代码
这会响应路由压入/弹出事件而被调用。在第 14 行和第 15 行,你能够看到 _allHeroesFor
调用,它能够在两个页面上找到全部 Hero。从第 21 行开始构建 _HeroFlightManifest
并启动旅程。从这里开始,有一堆动画的代码设置和边缘状况的处理。我建议你看一下整个类,这颇有意思,里面还有不少值得学习的东西。你也能够看一下这个。
Villains 要比 Hero 更简单。
Hero 和 3 个 Villain 使用(AppBar,Text,FAB)。
他们使用相同的机制来查找给定上下文的全部 Villain,他们还使用 NavigationObserver
自动对页面转换作出反应。但不是从一个屏幕到另外一个屏幕的动画,而是仅在它们各自的屏幕上作的动画。
处理动画时,一般使用 SingleTickerProviderStateMixin
或 TickerProviderStateMixin
。在这种状况下,动画不会在 StatefulWidget
中启动,所以咱们须要另外一种方法来访问 TickerProvider
。
class TransitionTickerProvider implements TickerProvider {
final bool enabled;
TransitionTickerProvider(this.enabled);
@override
Ticker createTicker(TickerCallback onTick) {
return new Ticker(onTick, debugLabel: 'created by $this')..muted = !this.enabled;
}
}
复制代码
自定义一个 ticker 很是简单。全部这一切都是为了实现 TickerProvider
接口并返回一个新的 Ticker
。
static Future playAllVillains(BuildContext context, {bool entrance = true}) {
List<_VillainState> villains = VillainController._allVillainssFor(context)
..removeWhere((villain) {
if (entrance) {
return !villain.widget.animateEntrance;
} else {
return !villain.widget.animateExit;
}
});
// 用于新页面动画的控制器,由于它的时间比实际页面转换更长
AnimationController controller = new AnimationController(vsync: TransitionTickerProvider(TickerMode.of(context)));
SequenceAnimationBuilder builder = new SequenceAnimationBuilder();
for (_VillainState villain in villains) {
builder.addAnimatable(
anim: Tween<double>(begin: 0.0, end: 1.0),
from: villain.widget.villainAnimation.from,
to: villain.widget.villainAnimation.to,
tag: villain.hashCode,
);
}
SequenceAnimation sequenceAnimation = builder.animate(controller);
for (_VillainState villain in villains) {
villain.startAnimation(sequenceAnimation[villain.hashCode]);
}
//开始动画
return controller.forward().then((_) {
controller.dispose();
});
}
复制代码
首先,全部不该该展现的 Villain(那些将 animateExit/animateEntrance 设置为 false 的人)都会被过滤掉。而后建立一个带有自定义 TickerProvider
的 AnimationController
。使用 SequenceAnimation 库,每一个 Villain
被分配一个动画,它们在各自的时间中运行 0.0 —— 1.0(from
和 to
持续时间)。最后,动画所有开始。当它们所有完成时,控制器被丢弃。
@override
Widget build(BuildContext context) {
Widget animatedWidget = widget.villainAnimation
.animatedWidgetBuilder(widget.villainAnimation.animatable.chain(CurveTween(curve: widget.villainAnimation.curve)).animate(_animation), widget.child);
if (widget.secondaryVillainAnimation != null) {
animatedWidget = widget.secondaryVillainAnimation.animatedWidgetBuilder(
widget.secondaryVillainAnimation.animatable.chain(CurveTween(curve: widget.secondaryVillainAnimation.curve)).animate(_animation), animatedWidget);
}
return animatedWidget;
}
复制代码
这可能看起来很可怕,但请先忍耐一下。让咱们看看第 3 行和第 4 行。widget.villainAnimation.animatedWidgetBuilder
是一个自定义的 typedef:
typedef Widget AnimatedWidgetBuilder(Animation animation, Widget child);
复制代码
它的工做是返回一个根据动画绘制的组件(大多数时候返回的组件是一个 AnimatedWidget
)。
它获得了 Villain 的 child 和这个动画:
widget.villainAnimation.animatable.chain(CurveTween(curve: widget.villainAnimation.curve)).animate(_animation)
复制代码
链方法首先评估 CurveTween
。而后它使用该值来评估调用它的 animatable
。这只是将所需的曲线添加到动画中。
这是关于 Villain 如何工做的粗略概述,请务必也查看源代码并大胆地提出大家的问题。
深夜,我坐在个人办公桌前,写下测试。几个小时后,每一次单独的测试都过去了,彷佛没有 bug。就在睡觉以前,我把全部的测试都放在一块儿,以确保它真的没问题。而后发生了这个:
每一个测试都只能单独经过。
我很困惑。每次测试都成功。果真,当我本身运行这两个测试时,它们很正常。可是当一块儿运行全部测试时,最后两个失败了。WTF。
第一反应显然是:“个人代码确定没错,它必定对测试的执行方式作了些什么!也许测试是并行播放所以相互干扰?也许是由于我使用了相同的键?”
Brian Egan 向我指出,删除一个特定的测试修复了错误并将其移到顶部使得其余全部测试也失败了。若是那不是“共享数据”那么我不知道是什么。
当我发现问题是什么时,我忍不住笑了。这正是在某些状况下使用静态变量很差的缘由。
基本上,预约义的动画都是静态的。我懒得为每一个动画编写一个方法来获取 VillainAnimation
所需的全部参数。因此我使 VillainAnimation
是可变的(坏主意)。这样我就没有必要在方法中明确写出全部必要的参数。使用时看起来像这样:
Villain(
villainAnimation: VillainAnimation.fromBottom(0.4)
..to = Duration(milliseconds: 150),
child: Text("HI"),
)
复制代码
打破一切的测试应该在页面转换完成后开始测试 Villain 转换。它将动画的起点设置为 1 秒。由于它是在静态引用上设置它,以后的测试使用它做为默认值。测试失败,由于动画没法在 1 秒到 750 毫秒之间运行。
修复很简单(使一切都不可变并在方法中传递参数)但我仍然以为这个小错误很是有趣。
感谢 Villain 恢复了好坏之间的平衡。
关于 #fluttervillains 的意见和讨论是受欢迎的。若是你使用 Villain 一块儿制做很酷的动画,我很但愿看到它。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。