如果你是一名安卓開發者,應該很熟悉 **共享元素變換(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移出螢幕。
動畫進行時
動畫的進行是依靠了 TweencreateRectTween
屬性,將這個變換TweenMaterialRectArcTween
,注意,這個預設的變換路徑是一條曲線。
動畫結束時: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<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
這個監聽,會在當前幀繪製完成後觸發。
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轉換再適合不過了,大家可以按照這個設計來練練手。
具體設計稿請看:dribbble.com/shots/54098…
參考Demo:gitee.com/yumi0629/Fl…