最近有小夥伴在群裡問“如何修改CupertinoPageRoute
進入動畫”,主要是想實現下面這個效果:
可能有人覺得,這不就是自帶效果嗎?我們可以和自帶效果對比下:
很明顯,兩者的進入動畫是不一樣的,自帶效果預設是一個從右往左的transition。那麼,這個進入動畫可以改嗎?CupertinoPageRoute
現有的自帶API是沒有這個介面的,所以我們需要魔改。
關於Flutter的路由動畫設計
在魔改之前,我覺得有必要講一下Flutter的路由動畫設計。在Flutter中,路由的push和pop動畫是一組的,具體體現就是:如果push動畫是Animation A
,那麼pop動畫就是Animation A.reverse()
。我們可以看下TransitionRoute
的原始碼:
@override
TickerFuture didPush() {
assert(_controller != null, '$runtimeType.didPush called before calling install() or after calling dispose().');
assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
_animation.addStatusListener(_handleStatusChanged);
return _controller.forward();
}
@override
bool didPop(T result) {
assert(_controller != null, '$runtimeType.didPop called before calling install() or after calling dispose().');
assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
_result = result;
_controller.reverse();
return super.didPop(result);
}
複製程式碼
很清楚,push的時候執行的是_controller.forward()
,而pop的時候執行的是_controller.reverse()
。這就解釋了為什麼CupertinoPageRoute
的預設進入動畫是從右往左的一個transition了,因為側滑返回(也就是pop動畫)一定是從左往右的transition,這就決定了push動畫是從右往左了。
關於CupertinoPageRoute的動畫設計
對路由動畫有了基本的瞭解以後,可以來看下CupertinoPageRoute
的動畫設計了。CupertinoPageRoute
的繼承關係是:CupertinoPageRoute --> PageRoute --> ModalRoute --> TransitionRoute --> OverlayRoute --> Route
。CupertinoPageRoute
中,路由transition是通過buildTransitions
這個方法來建立的:
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return buildPageTransitions<T>(this, context, animation, secondaryAnimation, child);
}
複製程式碼
這個方法的父方法源自ModalRoute
,並且在類_ModalScopeState
中被使用,我們可以看到頁面最終是被包裹在了一個AnimatedBuilder
控制元件中的,配合widget.route.buildTransitions
就可以實現各種動畫效果了:
class _ModalScopeState<T> extends State<_ModalScope<T>> {
······
@override
Widget build(BuildContext context) {
return _ModalScopeStatus(
route: widget.route,
isCurrent: widget.route.isCurrent, // _routeSetState is called if this updates
canPop: widget.route.canPop, // _routeSetState is called if this updates
child: Offstage(
offstage: widget.route.offstage, // _routeSetState is called if this updates
child: PageStorage(
bucket: widget.route._storageBucket, // immutable
child: FocusScope(
node: focusScopeNode, // immutable
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _listenable, // immutable
builder: (BuildContext context, Widget child) {
return widget.route.buildTransitions(
context,
widget.route.animation,
widget.route.secondaryAnimation,
IgnorePointer(
ignoring: widget.route.animation?.status == AnimationStatus.reverse,
child: child,
),
);
},
child: _page ??= RepaintBoundary(
key: widget.route._subtreeKey, // immutable
child: Builder(
builder: (BuildContext context) {
return widget.route.buildPage(
context,
widget.route.animation,
widget.route.secondaryAnimation,
);
},
),
),
),
),
),
),
),
);
}
······
}
複製程式碼
那麼這個_ModalScope
是何時被掛載到路由上的呢?繼續看ModalRoute
的原始碼,createOverlayEntries()
中初始化了這個_ModalScope
:
@override
Iterable<OverlayEntry> createOverlayEntries() sync* {
yield _modalBarrier = OverlayEntry(builder: _buildModalBarrier);
yield OverlayEntry(builder: _buildModalScope, maintainState: maintainState);
}
Widget _buildModalScope(BuildContext context) {
return _modalScopeCache ??= _ModalScope<T>(
key: _scopeKey,
route: this,
// _ModalScope calls buildTransitions() and buildChild(), defined above
);
}
複製程式碼
而createOverlayEntries()
則是在OverlayRoute
中的install()
方法中被呼叫的:
@override
void install(OverlayEntry insertionPoint) {
assert(_overlayEntries.isEmpty);
_overlayEntries.addAll(createOverlayEntries());
navigator.overlay?.insertAll(_overlayEntries, above: insertionPoint);
super.install(insertionPoint);
}
複製程式碼
這個install()
方法會在路由被插入進navigator的時候被呼叫,Flutter在這個時候填充overlayEntries,並且把它們新增到overlay中去。這個事情是由Route來做,而不是由Navigator來做是因為,Route還負責removing overlayEntries,這樣add和remove操作就是對稱的了。
上面這些綜合起來將就是:在路由intall的時候,widget.route.buildTransitions
方法給AnimatedBuilder提供了一個用來動畫的Transitions,從而使路由能動起來。
所以,要改變CupertinoPageRoute
的進入動畫,就要重寫這個widget.route.buildTransitions
方法。
自定義CupertinoPageTransition
剖析系統的CupertinoPageTransition
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return buildPageTransitions<T>(this, context, animation, secondaryAnimation, child);
}
static Widget buildPageTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
if (route.fullscreenDialog) {
return CupertinoFullscreenDialogTransition(
animation: animation,
child: child,
);
} else {
return CupertinoPageTransition(
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: isPopGestureInProgress(route),
child: _CupertinoBackGestureDetector<T>(
enabledCallback: () => _isPopGestureEnabled<T>(route),
onStartPopGesture: () => _startPopGesture<T>(route),
child: child,
),
);
}
}
複製程式碼
這裡解釋下buildTransitions()
方法中的兩個引數:animation
和secondaryAnimation
。
- 當Navigator push了一個新路由的時候,新路由的
animation
從0.0-->1.0變化;當Navigator pop最頂端的路由時(比如點選返回鍵),animation從1.0-->0.0變化。 - 當Navigator push了一個新路由的時候,原來的最頂端路由的
secondaryAnimation
從0.0-->1.0變化;當路由pop最頂端路由時,secondaryAnimation從1.0-->0.0變化。 簡單來說,animation
是我自己怎麼進來和出去,而secondaryAnimation
是別人覆蓋我的時候,我怎麼進來和出去。
所以,我們要對animation
進行一些修改,secondaryAnimation
不用管它。
class CupertinoPageTransition extends StatelessWidget {
/// Creates an iOS-style page transition.
///
/// * `primaryRouteAnimation` is a linear route animation from 0.0 to 1.0
/// when this screen is being pushed.
/// * `secondaryRouteAnimation` is a linear route animation from 0.0 to 1.0
/// when another screen is being pushed on top of this one.
/// * `linearTransition` is whether to perform primary transition linearly.
/// Used to precisely track back gesture drags.
CupertinoPageTransition({
Key key,
@required Animation<double> primaryRouteAnimation,
@required Animation<double> secondaryRouteAnimation,
@required this.child,
@required bool linearTransition,
}) : assert(linearTransition != null),
_primaryPositionAnimation =
(linearTransition
? primaryRouteAnimation
: CurvedAnimation(
// The curves below have been rigorously derived from plots of native
// iOS animation frames. Specifically, a video was taken of a page
// transition animation and the distance in each frame that the page
// moved was measured. A best fit bezier curve was the fitted to the
// point set, which is linearToEaseIn. Conversely, easeInToLinear is the
// reflection over the origin of linearToEaseIn.
parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
)
).drive(_kRightMiddleTween),
_secondaryPositionAnimation =
(linearTransition
? secondaryRouteAnimation
: CurvedAnimation(
parent: secondaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
)
).drive(_kMiddleLeftTween),
_primaryShadowAnimation =
(linearTransition
? primaryRouteAnimation
: CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut,
)
).drive(_kGradientShadowTween),
super(key: key);
// When this page is coming in to cover another page.
final Animation<Offset> _primaryPositionAnimation;
// When this page is becoming covered by another page.
final Animation<Offset> _secondaryPositionAnimation;
final Animation<Decoration> _primaryShadowAnimation;
/// The widget below this widget in the tree.
final Widget child;
@override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
final TextDirection textDirection = Directionality.of(context);
return SlideTransition(
position: _secondaryPositionAnimation,
textDirection: textDirection,
transformHitTests: false,
child: SlideTransition(
position: _primaryPositionAnimation,
textDirection: textDirection,
child: DecoratedBoxTransition(
decoration: _primaryShadowAnimation,
child: child,
),
),
);
}
}
複製程式碼
看CupertinoPageTransition
的原始碼,其實是將頁面包裹在了一個SlideTransition
中,而child是一個帶有手勢控制的_CupertinoBackGestureDetector
,這個我們不用改,也不管它。我們需要對SlideTransition
做一些修改,讓其在路由push的時候使用我們自定義的transition,在pop的時候還是保留原始的動畫和手勢控制。
修改SlideTransition
明確下我們的目的,我們希望達成的效果是這樣的:
SlideTransition(
position: 是push嗎
? 我們自己的push animation
: 系統自帶的_primaryPositionAnimation,
textDirection: textDirection,
child: DecoratedBoxTransition(
decoration: widget._primaryShadowAnimation,
child: widget.child,
),
),
複製程式碼
所以最終需要解決的就是判斷當前是push還是pop。我一開始是打算使用位移量來計算的,往右移就是pop,往左移就是push,但是push是帶手勢移動的,使用者可以拉扯頁面左右瞎jb滑,所以這個方案pass;然後我換了個思路,監聽動畫的狀態,動畫結束了,就改變一下“是push嗎”這個變數的值:
@override
void initState() {
super.initState();
widget.primaryRouteAnimation.addStatusListener((status) {
print("status:$status");
if (status == AnimationStatus.completed) {
isPush = !isPush;
setState(() {
print("setState isFrom = ${isPush}");
});
}
}
@override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
final TextDirection textDirection = Directionality.of(context);
return SlideTransition(
position: widget._secondaryPositionAnimation,
textDirection: textDirection,
transformHitTests: false,
child: SlideTransition(
position: isPush
? widget._primaryPositionAnimationPush
: widget._primaryPositionAnimation,
textDirection: textDirection,
child: DecoratedBoxTransition(
decoration: widget._primaryShadowAnimation,
child: widget.child,
),
),
);
}
複製程式碼
其中_primaryPositionAnimationPush
就是我們自定義的push動畫:
_primaryPositionAnimationPush = (linearTransition
? primaryRouteAnimation
: CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
))
.drive(_kTweenPush);
final Animatable<Offset> _kTweenPush = Tween<Offset>(
begin: Offset.zero,
end: Offset.zero,
);
複製程式碼
這裡要注意下,CupertinoPageTransition
本是一個StatelessWidget
,但是我們這裡涉及到了狀態改變,所以需要將其變為一個StatefulWidget
。
這樣已經基本實現效果了,只是還有一個小bug,那就是使用者在滑動push的時候,如果滑到一半取消了,那麼動畫還是會走completed的,那麼isPush狀態就不對了。我們可以列印下不同操作下primaryRouteAnimation的status,可以發現如下結果:
- push的時候:forward --> completed
- 正常pop的時候:forward --> reverse --> dismissed
- pop滑到一半取消的時候:forward --> completed
這段log也側面反映了上面說的,pop動畫其實是push動畫的reverse。我們根據這個規修改下primaryRouteAnimation的監聽:
@override
void initState() {
super.initState();
widget.primaryRouteAnimation.addStatusListener((status) {
print("status:$status");
if (status == AnimationStatus.completed) {
isPush = false;
setState(() {
print("setState isFrom = ${isPush}");
});
} else if (status == AnimationStatus.dismissed) {
isPush = true;
setState(() {
print("setState isFrom = ${isPush}");
});
}
});
}
複製程式碼
執行下,完全符合我們的需求。
我們可以修改_kTweenPush
,實現各種各樣的push變換:
-
從下往上:_kTweenPush = Tween(begin: const Offset(0.0, 1.0),end: Offset.zero,);
-
從右下往左上:_kTweenPush = Tween(begin: const Offset(1.0, 1.0),end: Offset.zero,);
而修改_kRightMiddleTween
,可以改變pop側滑動畫,比如斜著退出:
_kRightMiddleTween = Tween(begin: const Offset(1.0, 1.0),end: Offset.zero,);
反正各種騷操作,你們都可以試試。
如果我想加一個淡入淡出動畫呢?
因為CupertinoPageTransition
中已經將路由寫死為一個SlideTransition
了,如果要實現其他的transition,我們需要修改build()
方法:
_primaryPositionAnimationPush = (linearTransition
? primaryRouteAnimation
: CurvedAnimation(
parent: primaryRouteAnimation,
curve: Curves.linearToEaseOut,
reverseCurve: Curves.easeInToLinear,
))
.drive(Tween<double>(
begin: 0.0,
end: 1.0,
)),
@override
Widget build(BuildContext context) {
assert(debugCheckHasDirectionality(context));
final TextDirection textDirection = Directionality.of(context);
return SlideTransition(
position: widget._secondaryPositionAnimation,
textDirection: textDirection,
transformHitTests: false,
child: isPush
? FadeTransition(
opacity: widget._primaryPositionAnimationPush,
child: widget.child,
)
: SlideTransition(
position: widget._primaryPositionAnimation,
textDirection: textDirection,
child: DecoratedBoxTransition(
decoration: widget._primaryShadowAnimation,
child: widget.child,
),
));
}
複製程式碼
至於其他的什麼大小、旋轉等等變換,自己都試試啦,藉助xxxTransition
控制元件都能實現。
如果我要修改動畫時間呢?
改Duration就要方便很多了,直接重寫CupertinoPageRoute
的get transitionDuration
方法就可以啦:
class MyCupertinoPageRoute<T> extends CupertinoPageRoute<T> {
@override
Duration get transitionDuration => const Duration(seconds: 3);
}
複製程式碼