為頁面切換加入動畫效果

在不同路由(或介面)之間進行切換的時候,許多設計語言,例如 Material 設計,都定義了一些標準行為。但有時自定義路由會讓 app 看上去更加的獨特。為了更好的完成這一點,PageRouteBuilder 提供了一個 Animation 物件。這個 Animation 能夠透過結合 Tween 以及 Curve 物件來自定義路由轉換動畫。這篇指南將會展示如何在兩個路由之間切換時使用從螢幕底部動畫出來的路由。

要建立這個自定義路由動畫,這篇指南使用了以下步驟:

  1. 搭建一個 PageRouteBuilder

  2. 建立一個 Tween

  3. 新增一個 AnimatedWidget

  4. 使用 CurveTween

  5. 組合這兩個 Tween

1. 搭建一個 PageRouteBuilder

我們從使用一個 PageRouteBuilder 來建立一個 RoutePageRouteBuilder 有兩個回呼,第一個是建立這個路由的內容(pageBuilder),另一個則是建立一個路由的轉換器(transitionsBuilder)。

下面的範例將會建立兩個路由:一個首頁路由,包含了 “Go!” 按鈕,還有第二個路由,包含了一個顯示 “Page 2 的標題。

import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      home: Page1(),
    ),
  );
}

class Page1 extends StatelessWidget {
  const Page1({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(_createRoute());
          },
          child: const Text('Go!'),
        ),
      ),
    );
  }
}

Route _createRoute() {
  return PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => const Page2(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return child;
    },
  );
}

class Page2 extends StatelessWidget {
  const Page2({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: const Center(
        child: Text('Page 2'),
      ),
    );
  }
}

2. 建立一個 Tween

為了使新頁面從底部動畫出來,它應該從 Offset(0,1)Offset(0, 0) 進行動畫。(通常我們會使用 Offset.zero 構造器。)在這個情況下,對於 ‘FractionalTranslation’ widget 來說偏移量是一個 2D 向量值。將 dy 引數設為 1,這代表在豎直方向上切換整個頁面的高度。

transitionsBuilder 的回呼有一個 animation 引數。它其實是一個 Animation<double>,提供 0 到 1 的值。使用 Tween 來將 Animation 轉為 Animation

transitionsBuilder: (context, animation, secondaryAnimation, child) {
  const begin = Offset(0.0, 1.0);
  const end = Offset.zero;
  final tween = Tween(begin: begin, end: end);
  final offsetAnimation = animation.drive(tween);
  return child;
},

3. 使用 AnimatedWidget

Flutter 有一堆繼承自 AnimatedWidget 的 widget,它們能夠在動畫的值發生改變時自動重建自己。舉個例子,SlideTransition 拿到一個 Animation<Offset> 並在動畫改變時使用 FractionalTranslation widget 轉換其子級。

AnimatedWidget 回傳了一個帶有 Animation<Offset>SlideTransition,以及 child widget:

transitionsBuilder: (context, animation, secondaryAnimation, child) {
  const begin = Offset(0.0, 1.0);
  const end = Offset.zero;
  final tween = Tween(begin: begin, end: end);
  final offsetAnimation = animation.drive(tween);

  return SlideTransition(
    position: offsetAnimation,
    child: child,
  );
},

4. 使用 CurveTween

Flutter 提供了一系列緩和曲線,可以調整一段時間內的動畫速率。 Curves 類提供了一個提前定義的用法相似的 curves。例如,Curves.easeOut 將會讓動畫開始很快結束很慢。

要使用 Curve,建立一個 CurveTween 並傳一個 Curve:

var curve = Curves.ease;
var curveTween = CurveTween(curve: curve);

新的 Tween 依然提供 0 到 1 之間的值。在下一步中,它將會結合第二步中提到的 Tween<Offset>

5. 結合兩個 Tween

為了結合兩個 tween,請使用 chain():

const begin = Offset(0.0, 1.0);
const end = Offset.zero;
const curve = Curves.ease;

var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));

它們透過把這個 tween 傳遞給 animation.drive() 來建立一個新的 Animation<Offset>,然後你就能把它傳給 SlideTransition widget:

return SlideTransition(
  position: animation.drive(tween),
  child: child,
);

這個新的 Tween(或者是能夠動畫的東西)透過評估 CurveTween 來提供 Offset,然後評估 Tween<Offset>。當動畫執行時,值都被這條指令計算出:

  1. 這個動畫提供了從 0 到 1 的值。(透過 transitionsBuilder 的值提供)

  2. 這個 CurveTween 根據其將這些值對映到介於 0 和 1 之間的新曲線值。

  3. Tween<Offset>double 值對映為 Offset 值。

使用緩動曲線建立 Animation<Offset> 的另一種方法是使用 CurvedAnimation

transitionsBuilder: (context, animation, secondaryAnimation, child) {
  const begin = Offset(0.0, 1.0);
  const end = Offset.zero;
  const curve = Curves.ease;

  final tween = Tween(begin: begin, end: end);
  final curvedAnimation = CurvedAnimation(
    parent: animation,
    curve: curve,
  );

  return SlideTransition(
    position: tween.animate(curvedAnimation),
    child: child,
  );
}

互動式範例

import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      home: Page1(),
    ),
  );
}

class Page1 extends StatelessWidget {
  const Page1({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            Navigator.of(context).push(_createRoute());
          },
          child: const Text('Go!'),
        ),
      ),
    );
  }
}

Route _createRoute() {
  return PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => const Page2(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      const begin = Offset(0.0, 1.0);
      const end = Offset.zero;
      const curve = Curves.ease;

      var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));

      return SlideTransition(
        position: animation.drive(tween),
        child: child,
      );
    },
  );
}

class Page2 extends StatelessWidget {
  const Page2({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: const Center(
        child: Text('Page 2'),
      ),
    );
  }
}