談一談Flutter中的共享元素動畫Hero
如果你是一名安卓開發者,應該很熟悉 **共享元素變換(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

在這個時間點,Flutter做了三件事:
- 計算目標Hero的位置,然後算出對應的Rect;
- 把源Hero複製一份,繪製到Overlay上(就是繪製一個與源Hero大小、位置完全相同的Hero,作為目標Hero),然後改變它的Z軸屬性,讓它能顯示在所有路由之上;
- 把源Hero移出螢幕。
動畫進行時

createRectTween
屬性,將這個變換Tween
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中的配置。目標Hero沒有配置 createRectTween
時,才會使用源Hero的 createRectTween
:
Tween<Rect> _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
。
所以,如果你想要push、pop都遵循自定義的RectTween,請給fromHero和toHero都設定createRectTween屬性。 如果只設置fromHero的createRectTween屬性,則push時執行自定義createRectTween,pop時執行預設的MaterialRectArcTween。
Hero的實現
Hero中所有的變換,都是通過 HeroController
來實現的。但是,開啟Hero類的原始碼,你會發現,這個Hero控制元件內部什麼事情也沒有做,也沒有沒有繫結HeroController,只是純粹地在build方法中建立了一個普通的widget。
但是,思考一下,Hero是一個與路由相關的動畫控制元件,它並不是一個簡單的Widget,能管理路由切換動畫。這麼看來,Hero似乎應該屬於一個App級別的全域性控制元件(準確地說應該是HeroController)。不知道Flutter團隊是不是這麼想的,實際上,HeroController確實是在App級別就被初始化,並且和NavigatorObserver綁定了。這樣,每次Navigator進行push/pop操作時,HeroController都會收到通知。
我們可以開啟MaterialApp的原始碼:
@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<NavigatorObserver>.from(widget.navigatorObservers) ..add(_heroController); } else { _navigatorObservers = null; } } 複製程式碼
在MaterialApp初始化狀態的時候,就初始化好了 _heroController
,並且在 _updateNavigator()
方法中將其與 _navigatorObservers
繫結。 _createRectTween
返回的是一個MaterialRectArcTween,這解釋了之前提到的一個知識點:預設的Hero動畫的Rect是一個MaterialRectArcTween。
那麼新的疑問又來了,我們現在有了 _heroController
,這個 _heroController
是怎麼和我們佈局中的Hero控制元件聯絡起來的呢?
我們來看HeroController的原始碼:
@override void didPush(Route<dynamic> route, Route<dynamic> previousRoute) { ······ _maybeStartHeroTransition(previousRoute, route, HeroFlightDirection.push); } @override void didPop(Route<dynamic> route, Route<dynamic> previousRoute) { ······ _maybeStartHeroTransition(route, previousRoute, HeroFlightDirection.pop); } 複製程式碼
在頁面push和pop的時候,都呼叫了同一個方法 _maybeStartHeroTransition()
:
void _maybeStartHeroTransition(Route<dynamic> fromRoute, Route<dynamic> toRoute, , HeroFlightDirection flightType) { ······ WidgetsBinding.instance.addPostFrameCallback((Duration value) { _startHeroTransition(from, to, animation, flightType); }); } } 複製程式碼
這裡的 WidgetsBinding
的作用,就是將源路由與目標路由,和 _heroController
關聯起來。 WidgetsBinding.instance.addPostFrameCallback
這個監聽,會返回給我們4個值: PageRoute<dynamic> from
(源路由)、 PageRoute<dynamic> to
(目標路由)、 Animation<double> animation
和 HeroFlightDirection flightType
。
void _startHeroTransition( PageRoute<dynamic> from, PageRoute<dynamic> to, Animation<double> 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<Object, _HeroState> fromHeroes = Hero._allHeroesFor(from.subtreeContext); final Map<Object, _HeroState> toHeroes = Hero._allHeroesFor(to.subtreeContext); // If the `to` route was offstage, then we're implicitly restoring its // animation value back to what it was before it was "moved" offstage. to.offstage = false; for (Object tag in fromHeroes.keys) { if (toHeroes[tag] != null) { final HeroFlightShuttleBuilder fromShuttleBuilder = fromHeroes[tag].widget.flightShuttleBuilder; final HeroFlightShuttleBuilder toShuttleBuilder = toHeroes[tag].widget.flightShuttleBuilder; final _HeroFlightManifest manifest = _HeroFlightManifest( type: flightType, overlay: navigator.overlay, navigatorRect: navigatorRect, fromRoute: from, toRoute: to, fromHero: fromHeroes[tag], toHero: toHeroes[tag], createRectTween: createRectTween, shuttleBuilder: toShuttleBuilder ?? fromShuttleBuilder ?? _defaultHeroFlightShuttleBuilder, ); if (_flights[tag] != null) _flights[tag].divert(manifest); else _flights[tag] = _HeroFlight(_handleFlightEnded)..start(manifest); } else if (_flights[tag] != null) { _flights[tag].abort(); } } } 複製程式碼
_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( PageRoute<dynamic> from, PageRoute<dynamic> to, Animation<double> 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轉換再適合不過了,大家可以按照這個設計來練練手。
具體設計稿請看: ping-app%2Fattachments%2F1172372" rel="nofollow,noindex">dribbble.com/shots/54098…
參考Demo: gitee.com/yumi0629/Fl…