課程 | 在 Flutter 應用裡實現動畫效果

本課程將講解如何在 Flutter 中建構顯式動畫。我們先來介紹一些動畫函式庫中的基本概念,類和方法,然後列舉五個動畫範例。這些範例互相關聯,展示了動畫函式庫的不同方面。

Flutter SDK 也內建了顯式動畫,比如 FadeTransitionSizeTransitionSlideTransition。這些簡單的動畫可以透過設定起點和終點來觸發。它們比下面介紹的顯式動畫更容易實現。

基本動畫概念和類

Flutter 中的動畫系統基於型別化的 Animation 物件。 Widgets 既可以透過讀取當前值和監聽狀態變化直接合併動畫到 build 函式,也可以作為傳遞給其他 widgets 的更精細動畫的基礎。

Animation<double>

在 Flutter 中,動畫物件無法獲取螢幕上顯示的內容。 Animation 是一個已知當前值和狀態(已完成或已解除)的抽象類別。一個比較常見的動畫型別是 Animation<double>

一個 Animation 物件在一段時間內,持續產生介於兩個值之間的插入值。這個 Animation 物件輸出的可能是直線,曲線,階梯函式,或者任何自訂的對映。根據 Animation 物件的不同控制方式,它可以反向執行,或者中途切換方向。

動畫還可以插入除 double 以外的型別,比如 Animation<Color> 或者 Animation<Size>

Animation 物件具有狀態。它的當前值在 .value 中始終可用。

Animation 物件與渲染或 build() 函式無關。

Curved­Animation

CurvedAnimation 定義動畫處理序為非線性曲線。

animation = CurvedAnimation(parent: controller, curve: Curves.easeIn);

CurvedAnimationAnimationController(下面將會詳細說明)都是 Animation<double> 型別,所以可以互換使用。 CurvedAnimation 封裝正在修改的物件 — 不需要將 AnimationController 分解成子類別來實現曲線。

Animation­Controller

AnimationController 是個特殊的 Animation 物件,每當硬體準備新幀時,他都會產生一個新值。預設情況下,AnimationController 在給定期間內會線性產生從 0.0 到 1.0 的數字。例如,這段程式碼建立了一個動畫物件,但是沒有啟動執行。

controller =
    AnimationController(duration: const Duration(seconds: 2), vsync: this);

AnimationController 源自於 Animation<double>,所以可以用在任何需要 Animation 物件的地方。但是 AnimationController 還有其他方法控制動畫。例如,使用 .forward() 方法啟動動畫。數字的產生與螢幕重新整理關聯,所以一般來說每秒鐘會產生 60 個數字。數字產生之後,每個動畫物件都呼叫附加 Listener 物件。為每個 child 建立自訂顯示列表,請參考 RepaintBoundary

建立 AnimationController 的同時,也賦予了一個 vsync 引數。 vsync 的存在防止後臺動畫消耗不必要的資源。您可以透過新增 SingleTickerProviderStateMixin 到類定義,將有狀態的物件用作 vsync。可參考 GitHub 網站 animate1 中的範例。

Tween

在預設情況下,AnimationController 物件的範圍是 0.0-0.1。如果需要不同的範圍或者不同的資料型別,可以使用 Tween 配置動畫來插入不同的範圍或資料型別。例如下面的範例中,Tween 的範圍是 -200 到 0.0。

tween = Tween<double>(begin: -200, end: 0);

Tween 是無狀態的物件,只有 beginendTween 的這種單一用途用來定義從輸入範圍到輸出範圍的對映。輸入範圍一般為 0.0-1.0,但這並不是必須的。

Tween 源自 Animatable<T>,而不是 Animation<T>。像動畫這樣的可動畫元素不必重複輸出。例如,ColorTween 指定了兩種顏色之間的過程。

colorTween = ColorTween(begin: Colors.transparent, end: Colors.black54);

A Tween object doesn’t store any state. Instead, it provides the evaluate(Animation<double> animation) method that uses the transform function to map the current value of the animation (between 0.0 and 1.0), to the actual animation value.

Tween 物件不儲存任何狀態。而是提供 evaluate(Animation<double> animation) 方法,將對映函式應用於動畫當前值。 Animation 物件的當前值可以在 .value 方法中找到。 evaluate 函式還執行一些內部處理內容,比如確保當動畫值在 0.0 和1.0 時分別返回起始點和終點。

Tween.animate

要使用 Tween 物件,請在 Tween 呼叫 animate(),傳入控制器物件。例如,下面的程式碼在 500 ms 的處理序中產生 0-255 範圍內的整數值。

AnimationController controller = AnimationController(
    duration: const Duration(milliseconds: 500), vsync: this);
Animation<int> alpha = IntTween(begin: 0, end: 255).animate(controller);

下面的範例展示了一個控制器,一個曲線,和一個 Tween

AnimationController controller = AnimationController(
    duration: const Duration(milliseconds: 500), vsync: this);
final Animation<double> curve =
    CurvedAnimation(parent: controller, curve: Curves.easeOut);
Animation<int> alpha = IntTween(begin: 0, end: 255).animate(curve);

動畫通知

一個 Animation 物件可以有不止一個 ListenerStatusListener,用 addListener()addStatusListener() 來定義。當動畫值改變時呼叫 ListenerListener 最常用的操作是呼叫 setState() 進行重建。當一個動畫開始,結束,前進或後退時,會呼叫 StatusListener,用 AnimationStatus 來定義。下一部分有關於 addListener() 方法的範例,在 監控動畫過程 中也有 addStatusListener() 的範例。


動畫範例

這部分列舉了五個動畫範例,每個範例都提供了原始碼的連結。

渲染動畫

目前為止,我們學習瞭如何隨著時間產生數字序列。但螢幕上並未顯示任何內容。要顯示一個 Animation 物件,需將 Animation 物件儲存為您的 widget 成員,然後用它的值來決定如何繪製。

參考下面的應用程式,它沒有使用動畫繪製 Flutter logo。

import 'package:flutter/material.dart';

void main() => runApp(const LogoApp());

class LogoApp extends StatefulWidget {
  const LogoApp({super.key});

  @override
  State<LogoApp> createState() => _LogoAppState();
}

class _LogoAppState extends State<LogoApp> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: 300,
        width: 300,
        child: const FlutterLogo(),
      ),
    );
  }
}

原始碼: animate0

下面的程式碼是加入動畫效果的,logo 從無到全屏。當定義 AnimationController 時,必須要使用一個 vsync 物件。在 AnimationController 部分 會具體介紹 vsync 引數。

對比無動畫範例,改動部分被突出顯示:

{animate0 → animate1}/lib/main.dart
@@ -9,16 +9,39 @@
9
9
  State<LogoApp> createState() => _LogoAppState();
10
10
  }
11
- class _LogoAppState extends State<LogoApp> {
11
+ class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
12
+ late Animation<double> animation;
13
+ late AnimationController controller;
14
+
15
+ @override
16
+ void initState() {
17
+ super.initState();
18
+ controller =
19
+ AnimationController(duration: const Duration(seconds: 2), vsync: this);
20
+ animation = Tween<double>(begin: 0, end: 300).animate(controller)
21
+ ..addListener(() {
22
+ setState(() {
23
+ // The state that has changed here is the animation object’s value.
24
+ });
25
+ });
26
+ controller.forward();
27
+ }
28
+
12
29
  @override
13
30
  Widget build(BuildContext context) {
14
31
  return Center(
15
32
  child: Container(
16
33
  margin: const EdgeInsets.symmetric(vertical: 10),
17
- height: 300,
18
- width: 300,
34
+ height: animation.value,
35
+ width: animation.value,
19
36
  child: const FlutterLogo(),
20
37
  ),
21
38
  );
22
39
  }
40
+
41
+ @override
42
+ void dispose() {
43
+ controller.dispose();
44
+ super.dispose();
45
+ }
23
46
  }

原始碼: animate1

因為addListener() 函式呼叫 setState(),所以每次 Animation 產生一個新的數字,當前幀就被標記為 dirty,使得 build() 再次被呼叫。在 build() 函式中,container 會改變大小,因為它的高和寬都讀取 animation.value,而不是固定編碼值。當 State 物件銷燬時要清除控制器以防止記憶體溢位。

經過這些小改動,你成功建立了第一個 Flutter 動畫。

使用 Animated­Widget 進行簡化

AnimatedWidget 基本類可以從動畫程式碼中區分出核心 widget 程式碼。 AnimatedWidget 不需要保持 State 物件來 hold 動畫。可以新增下面的 AnimatedLogo 類:

lib/main.dart (AnimatedLogo)
class AnimatedLogo extends AnimatedWidget {
  const AnimatedLogo({super.key, required Animation<double> animation})
      : super(listenable: animation);

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: animation.value,
        width: animation.value,
        child: const FlutterLogo(),
      ),
    );
  }
}

在繪製時,AnimatedLogo 會讀取 animation 當前值。

LogoApp 持續控制 AnimationControllerTween,並將 Animation 物件傳給 AnimatedLogo

{animate1 → animate2}/lib/main.dart
@@ -1,10 +1,28 @@
1
1
  import 'package:flutter/material.dart';
2
2
  void main() => runApp(const LogoApp());
3
+ class AnimatedLogo extends AnimatedWidget {
4
+ const AnimatedLogo({super.key, required Animation<double> animation})
5
+ : super(listenable: animation);
6
+
7
+ @override
8
+ Widget build(BuildContext context) {
9
+ final animation = listenable as Animation<double>;
10
+ return Center(
11
+ child: Container(
12
+ margin: const EdgeInsets.symmetric(vertical: 10),
13
+ height: animation.value,
14
+ width: animation.value,
15
+ child: const FlutterLogo(),
16
+ ),
17
+ );
18
+ }
19
+ }
20
+
3
21
  class LogoApp extends StatefulWidget {
4
22
  const LogoApp({super.key});
5
23
  @override
6
24
  State<LogoApp> createState() => _LogoAppState();
7
25
  }
@@ -15,32 +33,18 @@
15
33
  @override
16
34
  void initState() {
17
35
  super.initState();
18
36
  controller =
19
37
  AnimationController(duration: const Duration(seconds: 2), vsync: this);
20
- animation = Tween<double>(begin: 0, end: 300).animate(controller)
21
- ..addListener(() {
22
- setState(() {
23
- // The state that has changed here is the animation object’s value.
24
- });
25
- });
38
+ animation = Tween<double>(begin: 0, end: 300).animate(controller);
26
39
  controller.forward();
27
40
  }
28
41
  @override
29
- Widget build(BuildContext context) {
30
- return Center(
31
- child: Container(
32
- margin: const EdgeInsets.symmetric(vertical: 10),
33
- height: animation.value,
34
- width: animation.value,
35
- child: const FlutterLogo(),
36
- ),
37
- );
38
- }
42
+ Widget build(BuildContext context) => AnimatedLogo(animation: animation);
39
43
  @override
40
44
  void dispose() {
41
45
  controller.dispose();
42
46
  super.dispose();
43
47
  }

原始碼: animate2

監控動畫過程

瞭解動畫何時改變狀態通常是很有用的,比如完成,前進或後退。可以透過 addStatusListener() 來獲得提示。下面是之前範例修改後的程式碼,這樣就可以監聽狀態的改變和更新。修改部分會突出顯示:

class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 2), vsync: this);
    animation = Tween<double>(begin: 0, end: 300).animate(controller)
      ..addStatusListener((status) => print('$status'));
    controller.forward();
  }
  // ...
}

執行這段程式碼,得到如下結果:

AnimationStatus.forward
AnimationStatus.completed

下一步,在起始或結束時,使用 addStatusListener() 反轉動畫。製造“呼吸”效果:

{animate2 → animate3}/lib/main.dart
@@ -35,7 +35,15 @@
35
35
  void initState() {
36
36
  super.initState();
37
37
  controller =
38
38
  AnimationController(duration: const Duration(seconds: 2), vsync: this);
39
- animation = Tween<double>(begin: 0, end: 300).animate(controller);
39
+ animation = Tween<double>(begin: 0, end: 300).animate(controller)
40
+ ..addStatusListener((status) {
41
+ if (status == AnimationStatus.completed) {
42
+ controller.reverse();
43
+ } else if (status == AnimationStatus.dismissed) {
44
+ controller.forward();
45
+ }
46
+ })
47
+ ..addStatusListener((status) => print('$status'));
40
48
  controller.forward();
41
49
  }

原始碼: animate3

使用 AnimatedBuilder 進行重構

animate3 範例程式碼中有個問題,就是改變動畫需要改變渲染 logo 的widget。較好的解決辦法是,將任務區分到不同類裡:

  • 渲染 logo

  • 定義動畫物件

  • 渲染過渡效果

您可以使用 AnimatedBuilder 類方法來完成分配。 AnimatedBuilder 作為渲染樹的一個單獨類別。像 AnimatedWidgetAnimatedBuilder 自動監聽動畫物件提示,並在必要時在 widget 樹中標出,所以這時不需要呼叫 addListener()

應用於 animate4 範例的 widget 樹長這樣:

AnimatedBuilder widget tree

從 widget 樹底部開始,渲染 logo 的程式碼很容易:

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

  // Leave out the height and width so it fills the animating parent
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 10),
      child: const FlutterLogo(),
    );
  }
}

圖表中間的三部分都是用 GrowTransition 中的 build() 方法建立的,如下。 GrowTransition widget 本身是無狀態的,而且擁有定義過渡動畫所需的一系列最終變數。 build() 函式建立並返回 AnimatedBuilderAnimatedBuilder 使用(Anonymous builder)方法並將 LogoWidget 物件作為引數。渲染過渡效果實際上是在(Anonymous builder)方法中完成的,該方法建立一個適當大小 Container 強制 LogoWidget 配合。

在下面這段程式碼中,一個比較棘手的問題是 child 看起來被指定了兩次。其實是 child 的外部參照被傳遞給了 AnimatedBuilder,再傳遞給匿名閉套件,然後用作 child 的物件。最終結果就是 AnimatedBuilder 被插入渲染樹的兩個 widgets 中間。

class GrowTransition extends StatelessWidget {
  const GrowTransition(
      {required this.child, required this.animation, super.key});

  final Widget child;
  final Animation<double> animation;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: animation,
        builder: (context, child) {
          return SizedBox(
            height: animation.value,
            width: animation.value,
            child: child,
          );
        },
        child: child,
      ),
    );
  }
}

最後,初始動畫的程式碼看起來很像 animate2 的範例。 initState() 方法建立了 AnimationControllerTween,然後用 animate() 繫結它們。神奇的是 build() 方法,它返回一個以LogoWidget 為 child 的 GrowTransition 物件,和一個驅動過渡的動畫物件。上面列出了三個主要因素。

{animate2 → animate4}/lib/main.dart
@@ -1,27 +1,47 @@
1
1
  import 'package:flutter/material.dart';
2
2
  void main() => runApp(const LogoApp());
3
- class AnimatedLogo extends AnimatedWidget {
4
- const AnimatedLogo({super.key, required Animation<double> animation})
5
- : super(listenable: animation);
3
+ class LogoWidget extends StatelessWidget {
4
+ const LogoWidget({super.key});
5
+
6
+ // Leave out the height and width so it fills the animating parent
7
+ @override
8
+ Widget build(BuildContext context) {
9
+ return Container(
10
+ margin: const EdgeInsets.symmetric(vertical: 10),
11
+ child: const FlutterLogo(),
12
+ );
13
+ }
14
+ }
15
+
16
+ class GrowTransition extends StatelessWidget {
17
+ const GrowTransition(
18
+ {required this.child, required this.animation, super.key});
19
+
20
+ final Widget child;
21
+ final Animation<double> animation;
6
22
  @override
7
23
  Widget build(BuildContext context) {
8
- final animation = listenable as Animation<double>;
9
24
  return Center(
10
- child: Container(
11
- margin: const EdgeInsets.symmetric(vertical: 10),
12
- height: animation.value,
13
- width: animation.value,
14
- child: const FlutterLogo(),
25
+ child: AnimatedBuilder(
26
+ animation: animation,
27
+ builder: (context, child) {
28
+ return SizedBox(
29
+ height: animation.value,
30
+ width: animation.value,
31
+ child: child,
32
+ );
33
+ },
34
+ child: child,
15
35
  ),
16
36
  );
17
37
  }
18
38
  }
19
39
  class LogoApp extends StatefulWidget {
20
40
  const LogoApp({super.key});
21
41
  @override
22
42
  State<LogoApp> createState() => _LogoAppState();
@@ -34,18 +54,23 @@
34
54
  @override
35
55
  void initState() {
36
56
  super.initState();
37
57
  controller =
38
58
  AnimationController(duration: const Duration(seconds: 2), vsync: this);
39
59
  animation = Tween<double>(begin: 0, end: 300).animate(controller);
40
60
  controller.forward();
41
61
  }
42
62
  @override
43
- Widget build(BuildContext context) => AnimatedLogo(animation: animation);
63
+ Widget build(BuildContext context) {
64
+ return GrowTransition(
65
+ animation: animation,
66
+ child: const LogoWidget(),
67
+ );
68
+ }
44
69
  @override
45
70
  void dispose() {
46
71
  controller.dispose();
47
72
  super.dispose();
48
73
  }
49
74
  }

原始碼: animate4

同步動畫

在這部分內容中,您會根據 監控動畫過程 (animate3) 建立範例,該範例將使用 AnimatedWidget 持續進行動畫。可以用在需要對透明度進行從透明到不透明動畫處理的情況。

每個補間動畫控制一個動畫的不同方面,例如:

controller =
    AnimationController(duration: const Duration(seconds: 2), vsync: this);
sizeAnimation = Tween<double>(begin: 0, end: 300).animate(controller);
opacityAnimation = Tween<double>(begin: 0.1, end: 1).animate(controller);

透過 sizeAnimation.value 我們可以得到尺寸,透過 opacityAnimation.value 可以得到不透明度,但是 AnimatedWidget 的建構函式唯讀取單一的 Animation 物件。為了解決這個問題,該範例建立了一個 Tween 物件並計算確切值。

修改 AnimatedLogo 來封裝其 Tween 物件,以及其 build() 方法在母動畫物件上呼叫 Tween.evaluate() 來計算所需的尺寸和不透明度值。下面的程式碼中將這些改動突出顯示:

class AnimatedLogo extends AnimatedWidget {
  const AnimatedLogo({super.key, required Animation<double> animation})
      : super(listenable: animation);

  // Make the Tweens static because they don't change.
  static final _opacityTween = Tween<double>(begin: 0.1, end: 1);
  static final _sizeTween = Tween<double>(begin: 0, end: 300);

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Center(
      child: Opacity(
        opacity: _opacityTween.evaluate(animation),
        child: Container(
          margin: const EdgeInsets.symmetric(vertical: 10),
          height: _sizeTween.evaluate(animation),
          width: _sizeTween.evaluate(animation),
          child: const FlutterLogo(),
        ),
      ),
    );
  }
}

class LogoApp extends StatefulWidget {
  const LogoApp({super.key});

  @override
  State<LogoApp> createState() => _LogoAppState();
}

class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller =
        AnimationController(duration: const Duration(seconds: 2), vsync: this);
    animation = CurvedAnimation(parent: controller, curve: Curves.easeIn)
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          controller.reverse();
        } else if (status == AnimationStatus.dismissed) {
          controller.forward();
        }
      });
    controller.forward();
  }

  @override
  Widget build(BuildContext context) => AnimatedLogo(animation: animation);

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

原始碼: animate5

下面的步驟

本指南是在 Flutter 中應用 Tweens 建立動畫的基礎介紹,還有很多其他類可供探索。比如指定 Tween 類,Material Design 特有的動畫, ReverseAnimation,共享元素過渡(也稱為 Hero 動畫),物理模擬和 fling() 方法。關於最新的文件和範例可參見 動畫效果介紹