Flutter如何為初始路由新增動畫?頁面中單個元素又如何隨路由動起來?

吉原拉麵發表於2019-01-30

  今天給大家將兩個關於路由的騷操作,雖然說專案裡不太會用到,但是看看漲漲姿勢總是好的。
  我想大家應該都知道,Flutter在push/pop路由的時候,都是可以自定義動畫的,路由動畫在Flutter裡面寫起來非常的靈活,一般來說在push的時候帶上一個自定義的Route就可以了:

Navigator.of(context).push(
    PageRouteBuilder(
        pageBuilder: (context, _, __) {
            return ProductDetailPage(
                product: product,
            );
        },
        transitionDuration:
            const Duration(milliseconds: 500),
        transitionsBuilder:
            (_, animation, __, child) {
                return FadeTransition(
                    opacity: animation,
                    child: FadeTransition(
                        opacity:
                            Tween(begin: 0.5, end: 1.0).animate(animation),
                                child: child,
                            ),
                        );
            }),
        );
複製程式碼

  transitionDuration定義路由動畫時間,transitionsBuilder中可以自定義路由動畫,旋轉、位移、縮放等等都可以。

  那麼,你有沒有想過下面這兩個問題呢?

  • push/pop路由的時候可以帶動畫,可是初始頁面該怎麼給它新增路由動畫呢(Flutter為了能快速開啟App,初始路由是預設不帶任何transition變換的)?
  • 我想在新頁面中做這麼一個效果:開啟頁面圖片動畫進入,關閉頁面圖片反向退出,除了自己手動控制,有沒有其他的方法呢?

  其實,這兩個問題都是屬於路由範疇

  問題一同樣可以使用PageRouteBuilder來解決,只是寫法有些不同。一般來說我們都是直接在MaterialApp中設定home屬性,配置初始頁面,這樣寫明顯是不行的啦,home屬性接受的是一個Widget,而不是一個路由。莫急,很快就教你們一個不一樣的設定初始頁面的寫法。

MaterialApp(
    home: MyHomePage(),
)
複製程式碼

  問題二完全可以在頁面開啟的時候播放一個動畫A,然後監聽頁面關閉,關閉時倒著播放動畫A。但是這麼做明顯不太優雅,我們換個角度來思考,從路由的角度來說,你的圖片進入/退出動畫不就是隨著路由動畫來進行的嗎?它們完全可以使用同一個controller,那麼你只需定義好動畫,將其和路由controller繫結,什麼時候開始動畫,什麼時候結束,都不需要你來手動控制。

如何為初始路由新增動畫

  同樣也是在MaterialApp中設定,但是不是設定home屬性,而是onGenerateRoute屬性,它和routes屬性很像,也是用來配置路由的:

MaterialApp(
    onGenerateRoute: (settings) {
            if (settings.isInitialRoute) {
              return createInitialRoute();
            }
          },
)

Route<dynamic> createInitialRoute() {
    return PageRouteBuilder(
        transitionDuration: const Duration(seconds: 1),
        pageBuilder: (BuildContext context, _, __) {
          return MyHomePage(title: 'Flutter YMUI');
        },
        transitionsBuilder: (_, animation, __, child) {
          return RotationTransition(
            turns: Tween(begin: 0.0, end: 1.0).animate(animation),
            child: ScaleTransition(
              scale: Tween(begin: 0.0, end: 1.0).animate(animation),
              child: child,
            ),
          );
        });
  }
複製程式碼

  onGenerateRoute屬性會告訴我們一個RouteSettings值,這個值有兩個重要的api:

  • settings.isInitialRoute判斷是否是初始路由;
  • settings.name返回路由名(和routes屬性中的路由名一樣,是一個字串,用於和push/pop時的name配對)

  因此,我們根據settings.isInitialRoute判斷是否是初始路由,如果是,那麼就替換成我們的自定義PageRouteBuilder,這個時候是不是就很熟悉啦,我們可以肆意新增路由動畫了。執行一下看下效果:

Flutter如何為初始路由新增動畫?頁面中單個元素又如何隨路由動起來?

  PS:關於homeroutesonGenerateRouteonUnknownRoute屬性的優先順序:

  Navigator會按照home---->routes---->onGenerateRoute---->onUnknownRoute的順序去尋找路由:

  • home,也就是初始路由,路徑為:/
  • routes,也就是我們一般定義路由對映的地方,它的優先順序會比onGenerateRoute,如果兩者定義的路由又重複,肯定是先找routes中的;
  • onGenerateRoute優先順序最低,用來處理homeroutes都沒有處理的路由,所以一般返回非空值;
  • onUnknownRoute如果說某個路由上面三個都沒處理,那麼就會由onUnknownRoute來處理這個路由。

頁面元素動畫如何和路由動畫繫結

  我們先看一下效果,理解一下我們的需求:

Flutter如何為初始路由新增動畫?頁面中單個元素又如何隨路由動起來?
  其實很簡單,就是圖片和文字從頁面底部進入和退出,你完全監聽頁面的開啟和退出,手動執行動畫,只是這樣有點兒麻煩。獲取路由動畫需要用到ModalRoute這個類中的ModalRoute.of(context).animation方法。
  我們先看下相關類的繼承關係:PageRouteBuilder<T> ----> PageRoute<T> ----> ModalRoute<T> ----> TransitionRoute<T> ----> OverlayRoute<T> ----> Route<T>,我們找原始碼可以發現控制路由動畫的controller是存在於TransitionRoute中的,所以它的子類都是可以獲取到這個屬性的,再仔細看原始碼就可以發現,子類中的ModalRoute有一個.of(context)的工廠函式,可以獲取到Route例項,那麼繫結就可以這麼寫了(就是將controller賦給你自定義的圖片變換動畫的parent):

Animation<double> controller;
Animation<Offset> imageTranslation;

void _buildAniamtion(){
    controller = ModalRoute.of(context).controller;
    imageTranslation = Tween(
        begin: Offset(0.0, 2.0),
        end: Offset(0.0, 0.0),
      ).animate(
        CurvedAnimation(
          parent: controller,
          curve: Interval(0.0, 0.67, curve: Curves.fastOutSlowIn),
        ),
      );
 }
複製程式碼

  其實這麼寫最終執行的時候,效果也是OK的,但是你會發現有一個警告:

info: The member 'controller' can only be used within instance members of subclasses of 'package:flutter/src/widgets/routes.dart'.

  也就是說,Flutter是不建議你在頁面中獲取這個controller的,因為這個屬性有@protected註解,雖說最後執行效果是沒問題的,但是有個警告總是不太好的。原始碼註釋終有一句話,說是controller控制的動畫是通過animation暴露出來的,所以,我們不妨來看看這個animation。我們很容易就能找到animationsecondaryAnimation成員變數,是不是看起來很熟悉?建立路由時的pageBuilder就給了我們兩個動畫值,就是這兩個動畫啦。所以ModalRoute.of(context).animation獲取到的animation就是PageRouteBuilder構建時,pageBuilder屬性中包含的animation

/// animation 對應 ModalRoute.of(context).animation
/// secondaryAnimation 對應 ModalRoute.of(context).secondaryAnimation
 pageBuilder: (BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation) {
            return TestPage();
    },
複製程式碼

  所以,我們可以這麼定義我們的動畫,將每一個animation都和路由的animation繫結起來就可以了(AnimationController是繼承自Animation<double>的,所以這裡將controlleranimation賦給你自定義的圖片變換動畫的parent是一個效果,這就是為什麼最終執行效果是一樣的了):

  Animation<double> navAnimation;
  Animation<Offset> imageTranslation;
  Animation<Offset> textTranslation;
  Animation<double> imageOpacity;
  Animation<double> textOpacity;

  void _buildAniamtion(){
    if (navAnimation == null) {
      navAnimation = ModalRoute.of(context).animation;
      imageTranslation = Tween(
        begin: Offset(0.0, 2.0),
        end: Offset(0.0, 0.0),
      ).animate(
        CurvedAnimation(
          parent: navAnimation,
          curve: Interval(0.0, 0.67, curve: Curves.fastOutSlowIn),
        ),
      );
      imageOpacity = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(
          parent: navAnimation,
          curve: Interval(0.0, 0.67, curve: Curves.easeIn),
        ),
      );
      textTranslation = Tween(
        begin: Offset(0.0, 1.0),
        end: Offset(0.0, 0.0),
      ).animate(
        CurvedAnimation(
          parent: navAnimation,
          curve: Interval(0.34, 0.84, curve: Curves.ease),
        ),
      );
      textOpacity = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(
          parent: navAnimation,
          curve: Interval(0.34, 0.84, curve: Curves.linear),
        ),
      );
    }
  }
複製程式碼

  接下來就剩最後一個問題了,什麼時候來進行這個繫結操作呢?一般來說,動畫的初始化我們會選擇在initState()中進行,但是如果我們將_buildAniamtion()方法放入initState()中執行的話,會報如下的錯誤:

The following assertion was thrown building Builder:

  inheritFromWidgetOfExactType(_ModalScopeStatus) or inheritFromElement() was called before _TestPageState.initState() completed.
  When an inherited widget changes, for example if the value of Theme.of() changes, its dependent widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor or an initState() method, then the rebuilt dependent widget will not reflect the changes in the inherited widget.
  Typically references to to inherited widgets should occur in widget build() methods. Alternatively, initialization based on inherited widgets can be placed in the didChangeDependencies method, which is called after initState and whenever the dependencies change thereafter.

  劃報錯重點:最後一段中:can be placed in the didChangeDependencies method,嗯,寫的很清楚了,我們在didChangeDependencies()中初始化動畫就可以了:

@override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _buildAniamtion();
  }
複製程式碼

  didChangeDependencies()是緊接著initState()後面執行的,原始碼註釋中有一句:

It is safe to call [BuildContext.inheritFromWidgetOfExactType] from this method.

  兩者的區別我也說不清楚,反正如果你在initState()中執行某些方法報錯,不妨試試放到didChangeDependencies()中去。
  補充:評論有人說將操作放置到 addPostFrameCallback((timeStamp){ }),也就是第一幀繪製完成之後,聽起來很有道理對不對??但是跟佈局渲染順序是矛盾的,因為第一幀繪製完成也就意味著build()方法走完了,但是我們的佈局初始化的時候肯定是需要用到動畫value的,比如下面這樣:

FractionalTranslation(
    translation: imageTranslation.value,
        hild: HeaderImage(),
    ),
複製程式碼

  而初始化佈局的時候,我們的自定義的圖片aniamtion還沒有初始化和繫結好呢,imageTranslation還沒有值,會報錯的。如果非要這麼寫,那麼就需要修改一下佈局了,我們可以先給imageTranslation賦一個預設值,然後在addPostFrameCallback((timeStamp){ })監聽中再去修改這個值,然後再重新整理狀態:

FractionalTranslation(
    translation:
        imageTranslation == null ? 0.0 : imageTranslation.value,
    child: HeaderImage(),
),

WidgetsBinding.instance.addPostFrameCallback((callback) {
      _buildAniamtion();
      setState(() { });
    });
複製程式碼

  TestPage.dart完整程式碼如下:

class TestPage extends StatefulWidget {
  @override
  _TestPageState createState() => _TestPageState();
}

class _TestPageState extends State<TestPage> {
  Animation<double> controller;
  Animation<Offset> imageTranslation;
  Animation<Offset> textTranslation;
  Animation<double> imageOpacity;
  Animation<double> textOpacity;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
      _buildAniamtion();  // 此處程式碼省略,見上面
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnimatedBuilder(
        animation: controller,
        builder: (BuildContext context, Widget child) {
          return Column(
            children: <Widget>[
              FractionalTranslation(
                translation: imageTranslation.value,
                child: HeaderImage(),
              ),
              Expanded(
                child: FractionalTranslation(
                  translation: textTranslation.value,
                  child: AppText(),
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

class AppText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(left: 12.0, right: 12.0, top: 44.0),
      child: Text(
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non lorem non justo congue feugiat ut a enim. Ut et sem nec lacus aliquet gravida. Mauris viverra lectus nec vulputate placerat. Nullam sit amet blandit massa, volutpat blandit arcu. Vivamus eu tellus tincidunt, vestibulum neque eu, sagittis neque. Phasellus vitae rutrum magna, eu finibus mi. Suspendisse eget laoreet metus. In mattis dui vitae vestibulum molestie. Curabitur bibendum ut purus in faucibus.",
        style: Theme.of(context).textTheme.body2,
      ),
    );
  }
}

class HeaderImage extends StatelessWidget {
  const HeaderImage({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ClipRRect(
      borderRadius: BorderRadius.circular(4.0),
      child: Image.asset(
        "images/food01.jpeg",
        height: 300.0,
        fit: BoxFit.cover,
      ),
    );
  }
}

複製程式碼

  PS:如何判斷當前頁面是否是初始頁面?如何獲取當前頁面路由名?
  ModalRoute.of(context).settings可以拿到當前路由的基礎配置資訊,RouteSettings這個類一開始的時候就提到過啦,可以取到布林值isInitialRoute和路由名。

關於ModalRoute的更多屬性,可以看下這個:Flutter當前路由屬性詳解

相關文章