如果你是一名安卓开发者,应该很熟悉 **共享元素变换(Shared Element Transition)**这个概念,它可以通过几行代码,就在两个Activity或者Fragment之间做出流畅的转场动画。
Google把这个概念也带到了Flutter里面,这就是我们今天要讲的主角——Hero控件。通过Hero,我们可以在两个路由之间,做出流畅的转场动画。注意,是两个路由(Route),在Flutter里面,Dialog也是路由,因此完全可以使用在Dialog的切换上。 我们看下效果图:Hero的使用
我们现在有两个元素:源控件和目标控件。要实现元素共享,首先,我们要将两个控件分别用Hero包裹,同时为它们设置相同的tag。
源路由中的Hero:Hero( tag: 'hero', child: Container( color: Colors.lightGreen, width: 50.0, height: 50.0, ));复制代码
目标路由中的Hero:
Hero( tag: 'hero', child: Container( color: Colors.orange, width: 150.0, height: 120.0, ));复制代码
接着,给源路由页面添加路由跳转逻辑:
GestureDetector( child: Hero( tag: 'hero', child: Container( color: Colors.orange, width: 150.0, height: 120.0, )), onTap: () { Navigator.of(context).push(MaterialPageRoute(builder: (_) { return ElementDetailPage(); })); }, );复制代码
就是这么简单,只需两步,你就可以完成这个Hero过度动画了,是不是超级方便呢?
Hero变换时做了什么?
Hero就是一个动画,所以我们将其拆分成三部分来说:动画开始时、动画进行中和动画结束时。
动画开始时:t=0.0
- 计算目标Hero的位置,然后算出对应的Rect;
- 把源Hero复制一份,绘制到Overlay上(就是绘制一个与源Hero大小、位置完全相同的Hero,作为目标Hero),然后改变它的Z轴属性,让它能显示在所有路由之上;
- 把源Hero移出屏幕。
动画进行时
createRectTween
属性,将这个变换Tween传给Hero,Hero内部进行移动动画的操作。默认情况下,使用的变换是 MaterialRectArcTween
,注意,这个 默认的变换路径是一条曲线。 动画结束时:t=1.0
- Flutter将Overlay中的Hero移除,现在Overlay中就是空白的了;
- 目标Hero出现在目标路由的最终位置;
- 源Hero在源路由中被恢复。
此处划重点!!
源Hero与目标Hero大小应一致,否则会出现溢出(overflow)!! overflow这个警告我们应该不陌生了,Flutter中必须随时遵循布局原则,一不小心就会给你送上overflow大礼包。createRectTween是个什么东西
我们通过自定义createRectTween,可以改变转换动画。下面是一个很简单的设置createRectTween属性的例子:
createRectTween: (Rect begin, Rect end) { return RectTween( begin: Rect.fromLTRB( begin.left, begin.top, begin.right, begin.bottom), end: Rect.fromLTRB(end.left, end.top, end.right, end.bottom), ); }复制代码
至于如何自定义createRectTween,可以看一下默认的MaterialRectArcTween
的实现,主要是重写下面三个方法:
@override set begin(Rect value) { } @override set end(Rect value) { } @override Rect lerp(double t) { }复制代码
自定义一个RectTween很复杂,这里不展开讲了。
这里要注意一个坑:createRectTween
属性会优先选用目标Hero中的配置。 Tween_doCreateRectTween(Rect begin, Rect end) { final CreateRectTween createRectTween = manifest.toHero.widget.createRectTween ?? manifest.createRectTween; if (createRectTween != null) return createRectTween(begin, end); return RectTween(begin: begin, end: end); }复制代码
Hero的默认变换为MaterialRectArcTween
。
Hero的实现
Hero中所有的变换,都是通过HeroController
来实现的。但是,打开Hero类的源码,你会发现,这个Hero控件内部什么事情也没有做,也没有没有绑定HeroController,只是纯粹地在build方法中创建了一个普通的widget。
@override void initState() { super.initState(); _heroController = HeroController(createRectTween: _createRectTween); _updateNavigator(); }RectTween _createRectTween(Rect begin, Rect end) { return MaterialRectArcTween(begin: begin, end: end); }void _updateNavigator() { if (widget.home != null || widget.routes.isNotEmpty || widget.onGenerateRoute != null || widget.onUnknownRoute != null) { _navigatorObservers = List.from(widget.navigatorObservers) ..add(_heroController); } else { _navigatorObservers = null; } }复制代码
在MaterialApp初始化状态的时候,就初始化好了_heroController
,并且在_updateNavigator()
方法中将其与_navigatorObservers
绑定。_createRectTween
返回的是一个MaterialRectArcTween,这解释了之前提到的一个知识点:默认的Hero动画的Rect是一个MaterialRectArcTween。
_heroController
,这个_heroController
是怎么和我们布局中的Hero控件联系起来的呢? 我们来看HeroController的源码: @override void didPush(Routeroute, Route previousRoute) { ······ _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push); } @override void didPop(Route route, Route previousRoute) { ······ _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop); }复制代码
在页面push和pop的时候,都调用了同一个方法_maybeStartHeroTransition()
:
void _maybeStartHeroTransition(RoutefromRoute, Route toRoute, , HeroFlightDirection flightType) { ······ WidgetsBinding.instance.addPostFrameCallback((Duration value) { _startHeroTransition(from, to, animation, flightType); }); } }复制代码
这里的WidgetsBinding
的作用,就是将源路由与目标路由,和_heroController
关联起来。WidgetsBinding.instance.addPostFrameCallback
这个监听,会在当前帧绘制完成后触发。
void _startHeroTransition( PageRoutefrom, PageRoute to, Animation animation, HeroFlightDirection flightType, ) { // If the navigator or one of the routes subtrees was removed before this // end-of-frame callback was called, then don't actually start a transition. if (navigator == null || from.subtreeContext == null || to.subtreeContext == null) { to.offstage = false; // in case we set this in _maybeStartHeroTransition return; } final Rect navigatorRect = _globalBoundingBoxFor(navigator.context); // At this point the toHeroes may have been built and laid out for the first time. final Map
_startHeroTransition()
的内容比较多,而且都很重要,我就直接全部贴上来了。首先,通过_allHeroesFor()
找到源路由和目标路由页面中所有的Hero控件,然后对比Tag
,如果找到了tag一致的Hero,那么就构建一份_HeroFlightManifest
,这个清单里面包括了页面变换所需要的各种属性。最后,调用_flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest);
函数,开始变换。至于变换的具体动画实现,这里就不多说了,主要是通过start()
函数开启动画,更新Hero的位置:
void start(_HeroFlightManifest initialManifest) { ······ if (manifest.type == HeroFlightDirection.pop) _proxyAnimation.parent = ReverseAnimation(manifest.animation); else _proxyAnimation.parent = manifest.animation; manifest.fromHero.startFlight(); manifest.toHero.startFlight(); heroRectTween = _doCreateRectTween( _globalBoundingBoxFor(manifest.fromHero.context), _globalBoundingBoxFor(manifest.toHero.context), ); overlayEntry = OverlayEntry(builder: _buildOverlay); manifest.overlay.insert(overlayEntry); }复制代码
结束动画时,我们可以看到,overlayEntry中的控件被remove掉了。
void _handleAnimationUpdate(AnimationStatus status) { if (status == AnimationStatus.completed || status == AnimationStatus.dismissed) { _proxyAnimation.parent = null; assert(overlayEntry != null); overlayEntry.remove(); overlayEntry = null; manifest.fromHero.endFlight(); manifest.toHero.endFlight(); onFlightEnded(this); } }复制代码
当目标路由被pop的时候又会发生什么呢?因为pop的时候,也是执行的_startHeroTransition()
方法,跟push的时候是一样的,只不过执行的动画是反着的,就不多说了:
void _startHeroTransition( PageRoutefrom, PageRoute to, Animation animation, HeroFlightDirection flightType, ) { ······ _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest); ·······}void start(_HeroFlightManifest initialManifest) { ······ if (manifest.type == HeroFlightDirection.pop) _proxyAnimation.parent = ReverseAnimation(manifest.animation); ······}复制代码
小练习
在Dribble上找到了这个设计图,我觉得用来联系Hero转换再适合不过了,大家可以按照这个设计来练练手。
具体设计稿请看: 参考Demo: