路由動畫


在〈Navigator 與 Route〉中談過「路由代表某個資源的銜接…銜接的資源除了畫面元件之外,還包含了原生平台相應的轉場動畫的效果」。

之前已經瞭解了 Flutter 動畫的原理,那麼可否自訂路由動畫呢?這可以透過 PageRouteBuilder,例如,在〈Navigator 與 MaterialPageRoute〉中可以看到,Android 上原生的路由動畫是由下往上,若想要改為由右而左呢?可以如下:

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(
  title: 'Openhome',
  home: MainPage(),
));

class MainPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('主畫面'),
      ),
      body: GestureDetector(
        onTap: () {
          Navigator.push(context,
              // 使用 PageRouteBuilder
              PageRouteBuilder(
                  transitionDuration: Duration(milliseconds: 500),
                  // 指定 pageBuilder
                  pageBuilder: (_, __, ___)  => DetailPage('說明'),
                  // 指定 transitionsBuilder
                  transitionsBuilder: (_, animation, __, child) {
                    return SlideTransition(
                      position: Tween<Offset>(
                        begin: Offset(-1.0, 0.0),
                        end: Offset(0.0, 0.0)
                      ).animate(animation),
                      child: child
                    );
                  }
              )
          );
        },
        child: Image.asset('images/caterpillar.png'),
      ),
    );
  }
}

class DetailPage extends StatelessWidget {
  String title;

  DetailPage(this.title);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: GestureDetector(
        onTap: () => Navigator.pop(context),
        child: Center(
          child: Image.asset('images/caterpillar.png'),
        ),
      ),
    );
  }
}

建立 PageRouteBuilder 時,必須指定 pageBuilder,它接受的三個參數分別是 BuildContextAnimation<double>Animation<double>,如果你不想指定 transitionsBuilder,也可以在 pageBuilder 中組建動畫,這時就會用到 Animation<double>

雖然可以在 pageBuilder 中組建動畫,不過建議 pageBuilder 只用來組建路由要銜接的頁面,動畫的部份由 transitionsBuilder 來處理,它接受的四個參數分別是 BuildContextAnimation<double>Animation<double>Widget,最後一個參數就是 pageBuilder 傳回的 Widget

要建立動畫,必須有 AnimationPageRouteBuilder 會自動建立、管理 Animation,並傳入 pageBuildertransitionsBuilder 中,通常會使用第二個參數的 Animation,第三個參數的 Animation 很少使用,這稍後再來談。

第二個參數的 Animation,在頁面進場時,其 value 會是 0.0 到 1.0,在頁面離場時會是 1.0 到 0.0,在上例中為了要實現滑入滑出,透過 Tween<Offset> 指定了 beginend 來套接了 Animation,最後完成的效果如下:

路由動畫

在上例中,剛好兩個頁面都有相同的元件(圖片),這可能是個產品詳細資訊頁面的切換,而在說明中,圖片是置中,這令滑動的效果呈現了不銜接的感覺,在過場動畫中,Flutter 提供了一個有趣的 Hero 元件,先看看它可以達成什麼效果:

路由動畫

要達到這個效果,只需要為你的「英雄」元件加上「標籤」:

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(
  title: 'Openhome',
  home: MainPage(),
));

class MainPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('主畫面'),
      ),
      body: GestureDetector(
        onTap: () {
          Navigator.push(context,
              PageRouteBuilder(
                  transitionDuration: Duration(milliseconds: 500),
                  pageBuilder: (_, animation, __)  => DetailPage('說明'),
                  transitionsBuilder: (_, animation, __, child) {
                    return SlideTransition(
                      position: Tween<Offset>(
                        begin: Offset(-1.0, 0.0),
                        end: Offset(0.0, 0.0)
                      ).animate(animation),
                      child: child
                    );
                  }
              )
          );
        },
        // 設定英雄標籤
        child: Hero(
          tag: 'caterpillar',
          child: Image.asset('images/caterpillar.png'),
        )
      ),
    );
  }
}

class DetailPage extends StatelessWidget {
  String title;

  DetailPage(this.title);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: GestureDetector(
        child: Center(
           // 英雄標籤必須對應
          child:  Hero(
            tag: 'caterpillar',
            child: Image.asset('images/caterpillar.png'),
          ),
        ),
      ),
    );
  }
}

tag 不一定要字串,可以是任何資料,只要設定相同的 tag 就可以了,設定不同的動畫,會影響的是整個頁面,例如:

...略

  PageRouteBuilder(
      transitionDuration: Duration(milliseconds: 500),
      pageBuilder: (_, animation, __)  => DetailPage('說明'),
      transitionsBuilder: (_, animation, animation2, child) {
        return ScaleTransition(
          scale: animation,
          child: child
        );
      }
  )

...略

會呈現出以下的效果:

路由動畫

至於英雄的移動路徑,可以藉由 placeholderBuilderflightShuttleBuilder 等來控制,有興趣可以自行查看 API 文件研究一下。

方才談到,PageRouteBuilderpageBuildertransitionsBuilder,第三個參數還有個 Animation<double>,若目前的 PageRouteBuilder 被堆疊時,它的 value 始終是 0,若其上頭堆疊了新的路由,它的 value 會是 0.0 到 1.0,若目前的 PageRouteBuilder 上方的路由被拿掉時,它的 value 會是 1.0 到 0.0,若目前的 PageRouteBuilder 離開堆疊時,它的 value 始終是 0。

簡單來說,第三個參數可以用在目前的 PageRouteBuilder 上方被堆疊新路由時的離場動畫,以及其上方堆疊的路由拿掉時的進場動畫,會說它很少使用的原因是,程式寫起來太複雜了,如果真要點程式碼來秀一下,底下是個範例,有興趣就自己研究一下:

import 'package:flutter/material.dart';

void main() => runApp(MaterialApp(
  title: 'Openhome',
  home: MainPage(),
));

class MainPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('主畫面'),
      ),
      body: GestureDetector(
        onTap: () {
          Navigator.push(context,
              PageRouteBuilder(
                  transitionDuration: Duration(milliseconds: 500),
                  pageBuilder: (_, animation, __)  => DetailPage('說明'),
                  transitionsBuilder: (_, animation, animation2, child) {
                    return ScaleTransition(
                      scale: animation,
                      child: SlideTransition(
                        position: Tween<Offset>(
                            begin: Offset(0.0, 0.0),
                            end: Offset(-1, 0.0)
                        ).animate(animation2),
                        child: child,
                      )
                    );
                  }
              )
          );
        },
        child: Hero(
          tag: 'caterpillar',
          child: Image.asset('images/caterpillar.png'),
        )
      ),
    );
  }
}

class DetailPage extends StatelessWidget {
  String title;

  DetailPage(this.title);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: GestureDetector(
        onTap: () {
          Navigator.push(context,
              PageRouteBuilder(
                  transitionDuration: Duration(milliseconds: 500),
                  pageBuilder: (_, animation, __)  => Scaffold(
                    appBar:  AppBar(
                      title: Text('阿散不魯'),
                    ),
                    body: Center(
                      child: Text('更多文字'),
                    ),
                  ),
                  transitionsBuilder: (_, animation, animation2, child) {
                    return ScaleTransition(
                        scale: animation,
                        child: child
                    );
                  }
              )
          );
        },
        child: Center(
          child:  Hero(
            tag: 'caterpillar',
            child: Image.asset('images/caterpillar.png'),
          ),
        ),
      ),
    );
  }
}

執行的效果如下:

路由動畫