Flutter自定义CupertinoPageRoute进入动画
最近有小伙伴在群里问“如何修改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);
}
转载自:https://juejin.cn/post/6844903901980786696