Flutter 搓个IOS风格的视差路由动画
前几天,有个群友在 FlutterCandies 群里问了个 IOS 侧滑路由返回的视差效果是如何实现的,恰巧我也正准备实现 IOS 那种从底部弹出的视差路由动画。
进入正题之前,可以先看看下面这几个内容:
PageRoutBuilder Secondary Animation is always 0 · Issue #94642 · flutter/flutter (github.com)
[iOS 13] new fullscreen stack type route transition · Issue #33798 · flutter/flutter (github.com)
侧滑视差
法老说过,不懂多看看源码。
- 先来点基础的,官方提供的
PageRouteBuilder
方便了路由的实现,这里我们只关心它的opaque
属性和buildPage
以及buildTransitions
两个方法。
opaque
表示新路由入栈后,旧路由是否继续绘制,剩下两个方法作用如其名,但是请留意下 animation
和 secondaryAnimation
这两个参数,稍后的路由联动就会使用到他们。
@override
final bool opaque;
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return pageBuilder(context, animation, secondaryAnimation);
}
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return transitionsBuilder(context, animation, secondaryAnimation, child);
}
- 现在,让我们看看
CupertinoPageRoute
的buildTransitions
是如何实现的。
从这里开始,就有趣了起来。CupertinoPageTransition
和 _CupertinoBackGestureDetector
,前者处理侧滑
和视差
的效果,后者处理手势让路由动画跟随指针移动。
static Widget buildPageTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final bool linearTransition = isPopGestureInProgress(route);
if (route.fullscreenDialog) {
return CupertinoFullscreenDialogTransition(
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: linearTransition,
child: child,
);
} else {
// 非Dialog走这里,上面演示的动画返回这个 Widget
return CupertinoPageTransition(
primaryRouteAnimation: animation,
secondaryRouteAnimation: secondaryAnimation,
linearTransition: linearTransition,
child: _CupertinoBackGestureDetector<T>(
enabledCallback: () => _isPopGestureEnabled<T>(route),
onStartPopGesture: () => _startPopGesture<T>(route),
child: child,
),
);
}
}
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return buildPageTransitions<T>(this, context, animation, secondaryAnimation, child);
}
- 再看看
CupertinoPageTransition
在搞什么名堂
好的,Everything is a Widget
,内部嵌套了两个SlideTransition
,第一个用secondaryAnimation
驱动,第二个用animation
驱动,至于为什么要嵌套两层,分别使用两个Animation
驱动,这里继续留个印象,因为视差动画相关。
class CupertinoPageTransition extends StatelessWidget {
CupertinoPageTransition({
Key? key,
required Animation<double> primaryRouteAnimation,
required Animation<double> secondaryRouteAnimation,
required this.child,
required bool linearTransition,
})
...
@override
Widget build(BuildContext 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,
),
),
);
}
}
在这里,回顾下刚刚的内容,路由类的 buildTransitions
方法用于构建路由动画,它的参数中的有animation
和secondaryAnimation
用来驱动动画。那么他们从哪里来?又被谁调用?下面来波小高能。
剖析路由动画源码族谱
瞧瞧 CupertinoPageRoute
的父类,以及各类的功能。
既然是路由动画,我们关心的就是这个 TransitionRoute
藏了什么东西。
芜,一个AnimationController
,两个Animation<double>
,注意到_secondaryAnimation
是一个ProxyAnimation
,默认为kAlwaysDismissedAnimation
,就是一个值一直是0的 Animation<double>
。
Animation<double>? _animation;
final ProxyAnimation _secondaryAnimation = ProxyAnimation(kAlwaysDismissedAnimation);
// AnimationController的创建
AnimationController createAnimationController() {
assert(!_transitionCompleter.isCompleted, 'Cannot reuse a $runtimeType after disposing it.');
final Duration duration = transitionDuration;
final Duration reverseDuration = reverseTransitionDuration;
assert(duration != null && duration >= Duration.zero);
return AnimationController(
duration: duration,
reverseDuration: reverseDuration,
debugLabel: debugLabel,
vsync: navigator!,
);
}
// 返回controller对应的Animation
Animation<double> createAnimation() {
return _controller!.view;
}
欸,是不是忘记了 AnimationController
和 animation
?
嘿嘿,藏在 install
方法里面。(不知道 install
干嘛的?请看文章开头的推荐阅读)
@override
void install() {
_controller = createAnimationController();
_animation = createAnimation()
..addStatusListener(_handleStatusChanged);
super.install();
if (_animation!.isCompleted && overlayEntries.isNotEmpty) {
overlayEntries.first.opaque = opaque;
}
}
到这你以为buildTransition
的参数就是上面这两个吗? ModalRoute
给你再来一手 ProxyAnimation
,至于为什么再来一层 Proxy,这里不深究了。
// 此方法为 ModalRoute 的 install 方法
@override
void install() {
super.install(); // 这里super是TransitionRoute的install
_animationProxy = ProxyAnimation(super.animation);
_secondaryAnimationProxy = ProxyAnimation(super.secondaryAnimation);
}
看得云里雾里?没事,不影响接下来的阅读,到这里,视差效果的实现还没有出现。
剖析路由动画调用过程
谁在调用 buildTrasition
?
package/lib/src/widgets/route.dart 里面的 _ModalScope
在做这件事,它是个 StatefulWidget。(懂不懂什么叫Everything is a widget啊)
当新路由被 push 的时候, 触发 build
方法,这里代码太多,只截取了一部分。你的路由动画都在 AnimatedBuilder
内。
我们再看看 push
一个路由后,会发生什么,样例代码如下
// 假设上面是个 MaterialApp
MaterialButton(
child: Text("go next Page"),
onPressed: () async {
Navigator.push(context,
CupertinoPageRoute(
builder: (context) {
return Scaffold(
appBar: AppBar(
title: Text("侧滑视差"),
),
body: Center(
child: Text("新路由"),
),
);
},
)
);
},
);
和路由动画有关的调用栈如下
那再 pop
一下。 嗯,发现总会执行 _updateSecondaryAnimation
。
到这里,上面看得云里雾里,没有理解也没关系,大概知道各个方法的作用,不用关心细节。只需知道
install
初始化路由动画控制器, _updateSecondaryAnimation
方法更新 secondaryAnimation
(就给 buildTransition
用的那个参数)
正式揭开视差路由的面纱
现在,就开始把前面的东西 All in 进来
在我们刚开始的那个页面,也就是MaterialApp
提供的默认 MaterialPageRoute
,它的 buildTrasiton
比较不一样,他会根据平台来展现不同的路由动画,默认就是从底部向上,IOS就侧滑视差,其他的就淡入淡出。
// MaterialRouteTransitionMixin 里的 buildTrasiton
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
final PageTransitionsTheme theme = Theme.of(context).pageTransitionsTheme;
return theme.buildTransitions<T>(this, context, animation, secondaryAnimation, child);
}
// theme 的 buildTransitions
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
TargetPlatform platform = Theme.of(context).platform;
if (CupertinoRouteTransitionMixin.isPopGestureInProgress(route)) // 留意一下这个东西
platform = TargetPlatform.iOS;
final PageTransitionsBuilder matchingBuilder =
builders[platform] ?? const FadeUpwardsPageTransitionsBuilder();
return matchingBuilder.buildTransitions<T>(route, context, animation, secondaryAnimation, child);
}
刚开始,我们当前的路由栈就一个 MaterialPageRoute
,当push
CupertinoPageRoute
的时候,奇妙的事情发生了!!!!!!!
还记得上面提及的那个 _updateSecondaryAnimation
吗?
- 当你
push
的时候,先调用CupertinoPageRoute
的_updateSecondaryAnimation
方法,nextRoute
表示CupertinoPageRoute
的下一个路由,由于push
后当前路由栈就MaterialPageRoute
和CupertinoPageRoute
,CupertinoPageRoute
之后没有新路由,所以是null
。
下面就是这个 CupertinoPageRoute
的路由动画信息
- 之后,再执行
MaterialPageRoute
的_updateSecondaryAnimation
,此时,传入的nextRoute
参数为新鲜push
的CupertinoPageRoute
,传入它,在_updateSecondaryAnimation
这个方法里面,经过各种if else
,会把CupertinoPageRoute
的AnimationController
设置为MaterialPageRoute
的secondaryAnimation(ProxyAnimation)
的parent
。
下面就是前一个路由 MaterialPageRoute
当前的路由动画信息
- 开始侧滑的时候,视差效果怎么来的呢?
这里提一下,当路由动画从开始到完全进入时候, AnimationController
的值变化为 0.0 -> 1.0
还记得前面提到得为什么是两个 SlideTrasition
吗?一个用于控制滑动视差
效果,一个用于控制侧边滑入
路由。
当检测到手势滑动的时候,isPopGestureInProgress(route)
(这个route是 MaterialPageRoute
)返回True,选择了两个 SlideTrasition
版本的动画,SlideTrasition
嵌套效果就是两个叠加。下面就是二者分别使用的Offset。
// 界面完全从右滑入
// Offset from offscreen to the right to fully on screen.
final Animatable<Offset> _kRightMiddleTween = Tween<Offset>(
begin: const Offset(1.0, 0.0),
end: Offset.zero,
);
// 界面从中心向左划出 1/3 个相对自身的距离
// Offset from fully on screen to 1/3 offscreen to the left.
final Animatable<Offset> _kMiddleLeftTween = Tween<Offset>(
begin: Offset.zero,
end: const Offset(-1.0 / 3.0, 0.0),
);
这个就是侧滑时,MaterialPageRoute
的路由动画信息。
由于MaterialPageRoute
初始的时候已经在屏幕中央,所以对应的 AnimationController.value = 1.0
这图是点击 push
后 MaterialPageRoute
的动画信息,自己的controller
一直为1.0,新路由的controller
一直在增加。对应的效果就是旧路由不变,新路由从右侧划入,但是这个时候没有视差效果,想知道为什么请看 CupertinoPageTrasition
的 linerTrasition
属性,这里不多做解释。
下图是完全push之后,再侧滑返回到屏幕中央,此时MaterialPageRoute
的动画信息。 animation
为值为1,表示在屏幕中央,secondaryAnimation
为0.5,对应向左偏移0.15, 也就对应了视差效果。
而CupertinoPageRoute
在这个时候(图中未给出),animation
为值为0.5,secondaryAnimation
为0.0( kAlwaysDismissedAnimation
),对应效果就是上面的路由滑动出半个屏幕。
- 侧滑是如何实现的?
见 Cupertino Package对应的route.dart,_CupertinoBackGestureController
的 朴实的dragUpdate
和 _CupertinoBackGestureDetector
这个 StatefulWidget
的 build
,看一眼就会了。
void dragUpdate(double delta) {
controller.value -= delta;
}
@override
Widget build(BuildContext context) {
double dragAreaWidth = Directionality.of(context) == TextDirection.ltr ?
MediaQuery.of(context).padding.left :
MediaQuery.of(context).padding.right;
dragAreaWidth = max(dragAreaWidth, _kBackGestureWidth);
return Stack(
fit: StackFit.passthrough,
children: <Widget>[
widget.child,
PositionedDirectional(
start: 0.0,
width: dragAreaWidth,
top: 0.0,
bottom: 0.0,
child: Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.translucent,
),
),
],
);
}
准备冻手,搓个视差路由动画
讲了那么多,其实就是一个 _updateSecondaryAnimation
方法,控制 secondaryAnimation
的 parent
,让旧路由把新路由的动画控制器借过来使用。
看看 _updateSecondaryAnimation
的源码
为了让路由动画联动,想让新旧路由一起执行某种动画效果,flutter 官方在 TransitionRoute
设计了一种契约,也就是对应 _updateSecondaryAnimation
的第一个if语句
// 默认都返回true,子类可重写来决定是否联动
// 当前路由状态变化时,前一个路由是否要更新动画
bool canTransitionFrom(TransitionRoute<dynamic> previousRoute) => true;
// 新路由的push时候,自己是否要更新动画
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) => true;
一切摸清了,动手就快多了,怕没有效果,全返回True就完事。
现在分析一下开头图2的那种视差效果。
后面的路由的状态保存了,所以新路由选择继承 PopupRoute
,滑动后有裁剪和缩放,新路由从底部向上划入界面。也就是说旧路由的 secondaryAnimation
驱动一个缩放动画, parent
为新页面的 AnimationController
,旧路由我们选择继承 PageRoute
,下面直接上代码。
// 新路由
@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation, Widget child) {
final double topOffset = paddingTop / MediaQuery.of(context).size.height;
final Animation<Offset> position = Tween<Offset>(
begin: const Offset(0.0, 1.0), end: Offset(0.0, topOffset))
.animate(animation);
return SlideTransition(
position: position,
...
);
}
// 旧路由
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final Animation<Offset> position =
Tween<Offset>(begin: const Offset(1.0, 0.0), end: Offset.zero)
.animate(animation);
final Animation<double> scaleFactor =
Tween<double>(begin: 1.0, end: 0.94).animate(secondaryAnimation);
return SlideTransition(
position: position,
child: ScaleTransition(
scale: scaleFactor,
child: ClipRRect(
borderRadius: secondaryAnimation.isDismissed
? BorderRadius.zero
: const BorderRadius.only(
topLeft: Radius.circular(35.0),
topRight: Radius.circular(35.0)),
child: child),
),
);
}
最后加个拖拽手势处理(参考官方源码),再写字数就太多了。
让我们看看效果,差了亿点点细节,这里懒得再深究。
总结
不融入路由,那么这个效果有手就行。但毕竟是flutter,看点源码折磨自己,然后醍醐灌顶也是蛮有意思的。
这种实现其实社区有个package叫modal_bottom_sheet | Flutter Package (flutter-io.cn),但是他的实现则是自己搞一个 AnimationControler
来控制, 在 didChangeNext
调用的时候自己进行动画控制器的设定,另辟蹊径属于是,和这篇文章的实现还是有些区别的。相同点就是得要自己构建两个 Route
,因为这是联动所需要的契约决定的。
上述效果实现的源代码请在此处自取 Chinouo/flutter_modal_bottom_route,也欢迎大家加入FlutterCandies作死🤪,群里卧虎藏龙!
最后祝大家虎年coding时龙精虎猛!😀
转载自:https://juejin.cn/post/7059725840958881828