課程 | 在 Flutter 應用裡實現動畫效果
本課程將講解如何在 Flutter 中建構顯式動畫。我們先來介紹一些動畫函式庫中的基本概念,類和方法,然後列舉五個動畫範例。這些範例互相關聯,展示了動畫函式庫的不同方面。
Flutter SDK 也內建了顯式動畫,比如
FadeTransition
,SizeTransition
和 SlideTransition
。這些簡單的動畫可以透過設定起點和終點來觸發。它們比下面介紹的顯式動畫更容易實現。
基本動畫概念和類
Flutter 中的動畫系統基於型別化的 Animation
物件。
Widgets 既可以透過讀取當前值和監聽狀態變化直接合併動畫到 build 函式,也可以作為傳遞給其他 widgets
的更精細動畫的基礎。
<double>
Animation在 Flutter 中,動畫物件無法獲取螢幕上顯示的內容。
Animation
是一個已知當前值和狀態(已完成或已解除)的抽象類別。一個比較常見的動畫型別是 Animation<double>
。
一個 Animation
物件在一段時間內,持續產生介於兩個值之間的插入值。這個 Animation
物件輸出的可能是直線,曲線,階梯函式,或者任何自訂的對映。根據 Animation
物件的不同控制方式,它可以反向執行,或者中途切換方向。
動畫還可以插入除 double 以外的型別,比如 Animation<Color>
或者 Animation<Size>
。
Animation
物件具有狀態。它的當前值在 .value
中始終可用。
Animation
物件與渲染或 build()
函式無關。
CurvedAnimation
CurvedAnimation
定義動畫處理序為非線性曲線。
animation = CurvedAnimation(parent: controller, curve: Curves.easeIn);
CurvedAnimation
和 AnimationController
(下面將會詳細說明)都是 Animation<double>
型別,所以可以互換使用。
CurvedAnimation
封裝正在修改的物件 —
不需要將 AnimationController
分解成子類別來實現曲線。
AnimationController
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
是無狀態的物件,只有 begin
和 end
。
Tween
的這種單一用途用來定義從輸入範圍到輸出範圍的對映。輸入範圍一般為 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
物件可以有不止一個
Listener
和 StatusListener
,用 addListener()
和 addStatusListener()
來定義。當動畫值改變時呼叫 Listener
。
Listener
最常用的操作是呼叫 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
引數。
對比無動畫範例,改動部分被突出顯示:
@@ -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:
|
18
|
-
width:
|
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 動畫。
使用 AnimatedWidget 進行簡化
AnimatedWidget
基本類可以從動畫程式碼中區分出核心 widget 程式碼。
AnimatedWidget
不需要保持 State
物件來 hold 動畫。可以新增下面的 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
持續控制 AnimationController
和 Tween
,並將 Animation
物件傳給 AnimatedLogo
:
@@ -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()
反轉動畫。製造“呼吸”效果:
@@ -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
作為渲染樹的一個單獨類別。像 AnimatedWidget
,AnimatedBuilder
自動監聽動畫物件提示,並在必要時在 widget 樹中標出,所以這時不需要呼叫 addListener()
。
應用於 animate4 範例的 widget 樹長這樣:
從 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() 函式建立並返回 AnimatedBuilder
,
AnimatedBuilder
使用(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()
方法建立了 AnimationController
和 Tween
,然後用 animate()
繫結它們。神奇的是 build()
方法,它返回一個以LogoWidget
為 child 的 GrowTransition
物件,和一個驅動過渡的動畫物件。上面列出了三個主要因素。
@@ -1,27 +1,47 @@
|
|
1
1
|
import 'package:flutter/material.dart';
|
2
2
|
void main() => runApp(const LogoApp());
|
3
|
-
class
|
4
|
-
const
|
5
|
-
|
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:
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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)
|
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()
方法。關於最新的文件和範例可參見 動畫效果介紹。