Flutter自定義CupertinoPageRoute進入動畫

吉原拉麵發表於2019-07-29

  github.com/yumi0629/Fl…

  最近有小夥伴在群裡問“如何修改CupertinoPageRoute進入動畫”,主要是想實現下面這個效果:

Flutter自定義CupertinoPageRoute進入動畫

  可能有人覺得,這不就是自帶效果嗎?我們可以和自帶效果對比下:

Flutter自定義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,);

    Flutter自定義CupertinoPageRoute進入動畫

  • 從右下往左上:_kTweenPush = Tween(begin: const Offset(1.0, 1.0),end: Offset.zero,);

    Flutter自定義CupertinoPageRoute進入動畫

  而修改_kRightMiddleTween,可以改變pop側滑動畫,比如斜著退出: _kRightMiddleTween = Tween(begin: const Offset(1.0, 1.0),end: Offset.zero,);

Flutter自定義CupertinoPageRoute進入動畫

  反正各種騷操作,你們都可以試試。

如果我想加一個淡入淡出動畫呢?

  因為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,
                ),
              ));
  }
複製程式碼

Flutter自定義CupertinoPageRoute進入動畫

  至於其他的什麼大小、旋轉等等變換,自己都試試啦,藉助xxxTransition控制元件都能實現。

如果我要修改動畫時間呢?

  改Duration就要方便很多了,直接重寫CupertinoPageRouteget transitionDuration方法就可以啦:

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

相關文章