likes
comments
collection
share

Flutter 搓个IOS风格的视差路由动画

作者站长头像
站长
· 阅读数 9
前几天,有个群友在 FlutterCandies 群里问了个 IOS 侧滑路由返回的视差效果是如何实现的,恰巧我也正准备实现 IOS 那种从底部弹出的视差路由动画。

Flutter 搓个IOS风格的视差路由动画Flutter 搓个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)

侧滑视差

法老说过,不懂多看看源码。

  1. 先来点基础的,官方提供的 PageRouteBuilder 方便了路由的实现,这里我们只关心它的 opaque 属性和 buildPage 以及 buildTransitions两个方法。

opaque 表示新路由入栈后,旧路由是否继续绘制,剩下两个方法作用如其名,但是请留意下 animationsecondaryAnimation 这两个参数,稍后的路由联动就会使用到他们。

  @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);
  }
  1. 现在,让我们看看 CupertinoPageRoutebuildTransitions 是如何实现的。

从这里开始,就有趣了起来。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);
  }
  1. 再看看 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 方法用于构建路由动画,它的参数中的有animationsecondaryAnimation用来驱动动画。那么他们从哪里来?又被谁调用?下面来波小高能。

剖析路由动画源码族谱

瞧瞧 CupertinoPageRoute 的父类,以及各类的功能。

Flutter 搓个IOS风格的视差路由动画

既然是路由动画,我们关心的就是这个 TransitionRoute 藏了什么东西。

Flutter 搓个IOS风格的视差路由动画

芜,一个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;
  }  

欸,是不是忘记了 AnimationControlleranimation

嘿嘿,藏在 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);
  }

Flutter 搓个IOS风格的视差路由动画

看得云里雾里?没事,不影响接下来的阅读,到这里,视差效果的实现还没有出现。

剖析路由动画调用过程

谁在调用 buildTrasition?

package/lib/src/widgets/route.dart 里面的 _ModalScope在做这件事,它是个 StatefulWidget。(懂不懂什么叫Everything is a widget啊)

当新路由被 push 的时候, 触发 build 方法,这里代码太多,只截取了一部分。你的路由动画都在 AnimatedBuilder 内。

Flutter 搓个IOS风格的视差路由动画

我们再看看 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("新路由"),
                    ),
                 );
               },
             )
           );  
         },
      );

和路由动画有关的调用栈如下

Flutter 搓个IOS风格的视差路由动画

那再 pop 一下。 嗯,发现总会执行 _updateSecondaryAnimation

Flutter 搓个IOS风格的视差路由动画

到这里,上面看得云里雾里,没有理解也没关系,大概知道各个方法的作用,不用关心细节。只需知道 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 吗?

  1. 当你 push 的时候,先调用 CupertinoPageRoute_updateSecondaryAnimation方法,nextRoute 表示 CupertinoPageRoute 的下一个路由,由于 push 后当前路由栈就 MaterialPageRouteCupertinoPageRouteCupertinoPageRoute 之后没有新路由,所以是 null

Flutter 搓个IOS风格的视差路由动画

下面就是这个 CupertinoPageRoute 的路由动画信息

Flutter 搓个IOS风格的视差路由动画

  1. 之后,再执行 MaterialPageRoute_updateSecondaryAnimation,此时,传入的 nextRoute 参数为新鲜 pushCupertinoPageRoute,传入它,在 _updateSecondaryAnimation 这个方法里面,经过各种 if else ,会把CupertinoPageRouteAnimationController 设置为 MaterialPageRoutesecondaryAnimation(ProxyAnimation)parent

Flutter 搓个IOS风格的视差路由动画

下面就是前一个路由 MaterialPageRoute 当前的路由动画信息

Flutter 搓个IOS风格的视差路由动画

  1. 开始侧滑的时候,视差效果怎么来的呢?

这里提一下,当路由动画从开始到完全进入时候, 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 的路由动画信息。 Flutter 搓个IOS风格的视差路由动画

由于MaterialPageRoute初始的时候已经在屏幕中央,所以对应的 AnimationController.value = 1.0

这图是点击 pushMaterialPageRoute的动画信息,自己的controller一直为1.0,新路由的controller一直在增加。对应的效果就是旧路由不变,新路由从右侧划入,但是这个时候没有视差效果,想知道为什么请看 CupertinoPageTrasitionlinerTrasition 属性,这里不多做解释。

Flutter 搓个IOS风格的视差路由动画

下图是完全push之后,再侧滑返回到屏幕中央,此时MaterialPageRoute的动画信息。 animation 为值为1,表示在屏幕中央,secondaryAnimation为0.5,对应向左偏移0.15, 也就对应了视差效果。

CupertinoPageRoute在这个时候(图中未给出),animation 为值为0.5,secondaryAnimation 为0.0( kAlwaysDismissedAnimation ),对应效果就是上面的路由滑动出半个屏幕。

Flutter 搓个IOS风格的视差路由动画

  1. 侧滑是如何实现的?

见 Cupertino Package对应的route.dart,_CupertinoBackGestureController的 朴实的dragUpdate_CupertinoBackGestureDetector这个 StatefulWidgetbuild,看一眼就会了。

 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 方法,控制 secondaryAnimationparent,让旧路由把新路由的动画控制器借过来使用。

看看 _updateSecondaryAnimation 的源码 Flutter 搓个IOS风格的视差路由动画

为了让路由动画联动,想让新旧路由一起执行某种动画效果,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 ,下面直接上代码。

Flutter 搓个IOS风格的视差路由动画

  // 新路由
  @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 搓个IOS风格的视差路由动画

总结

不融入路由,那么这个效果有手就行。但毕竟是flutter,看点源码折磨自己,然后醍醐灌顶也是蛮有意思的。

这种实现其实社区有个package叫modal_bottom_sheet | Flutter Package (flutter-io.cn),但是他的实现则是自己搞一个 AnimationControler 来控制, 在 didChangeNext 调用的时候自己进行动画控制器的设定,另辟蹊径属于是,和这篇文章的实现还是有些区别的。相同点就是得要自己构建两个 Route,因为这是联动所需要的契约决定的。

上述效果实现的源代码请在此处自取 Chinouo/flutter_modal_bottom_route,也欢迎大家加入FlutterCandies作死🤪,群里卧虎藏龙!

最后祝大家虎年coding时龙精虎猛!😀