Flutter自定义CupertinoPageRoute进入动画

8,432 阅读7分钟

  github.com/yumi0629/Fl…

  最近有小伙伴在群里问“如何修改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 --> RouteCupertinoPageRoute中,路由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()方法中的两个参数:animationsecondaryAnimation

  • 当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就要方便很多了,直接重写CupertinoPageRouteget transitionDuration方法就可以啦:

class MyCupertinoPageRoute<T> extends CupertinoPageRoute<T> {
@override
  Duration get transitionDuration => const Duration(seconds: 3);
}