Flutter自定義CupertinoPageRoute進入動畫

  github.com/yumi0629/Fl…node

  最近有小夥伴在羣裏問「如何修改CupertinoPageRoute進入動畫」,主要是想實現下面這個效果:git

  可能有人以爲,這不就是自帶效果嗎?咱們能夠和自帶效果對比下: github

  很明顯,二者的進入動畫是不同的,自帶效果默認是一個從右往左的transition。那麼,這個進入動畫能夠改嗎?CupertinoPageRoute現有的自帶API是沒有這個接口的,因此咱們須要魔改。bash

關於Flutter的路由動畫設計

  在魔改以前,我以爲有必要講一下Flutter的路由動畫設計。在Flutter中,路由的push和pop動畫是一組的,具體體現就是:若是push動畫是Animation A,那麼pop動畫就是Animation A.reverse()。咱們能夠看下TransitionRoute的源碼:less

@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動畫是從右往左了。ide

關於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就能夠實現各類動畫效果了:ui

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()中初始化了這個_ModalScopethis

@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()方法中被調用的:spa

@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);
}
複製代碼
相關文章
相關標籤/搜索