在上一篇文章中,我帶你一起學習了 Flutter 中實現頁面路由的兩種方式:基本路由與命名路由,即手動創建頁面進行切換,和通過前置路由注冊后提供標識符進行跳轉。除此之外,Flutter 還在這兩種路由方式的基礎上,支持頁面打開和頁面關閉傳遞參數,可以更精確地控制路由切換。
通過前面第[12](https://time.geekbang.org/column/article/110292)、[13](https://time.geekbang.org/column/article/110859)、[14](https://time.geekbang.org/column/article/110848)和[15](https://time.geekbang.org/column/article/111673)篇文章的學習,我們已經掌握了開發一款樣式精美的小型 App 的基本技能。但當下,用戶對于終端頁面的要求已經不再滿足于只能實現產品功能,除了樣式美觀之外,還希望交互良好、有趣、自然。
動畫就是提升用戶體驗的一個重要方式,一個恰當的組件動畫或者頁面切換動畫,不僅能夠緩解用戶因為等待而帶來的情緒問題,還會增加好感。Flutter 既然完全接管了渲染層,除了靜態的頁面布局之外,對組件動畫的支持自然也不在話下。
因此在今天的這篇文章中,我會向你介紹 Flutter 中動畫的實現方法,看看如何讓我們的頁面動起來。
## Animation、AnimationController 與 Listener
動畫就是動起來的畫面,是靜態的畫面根據事先定義好的規律,在一定時間內不斷微調,產生變化效果。而動畫實現由靜止到動態,主要是靠人眼的視覺殘留效應。所以,對動畫系統而言,為了實現動畫,它需要做三件事兒:
1. 確定畫面變化的規律;
2. 根據這個規律,設定動畫周期,啟動動畫;
3. 定期獲取當前動畫的值,不斷地微調、重繪畫面。
這三件事情對應到 Flutter 中,就是 Animation、AnimationController 與 Listener:
1. Animation 是 Flutter 動畫庫中的核心類,會根據預定規則,在單位時間內持續輸出動畫的當前狀態。Animation 知道當前動畫的狀態(比如,動畫是否開始、停止、前進或者后退,以及動畫的當前值),但卻不知道這些狀態究竟應用在哪個組件對象上。換句話說,Animation 僅僅是用來提供動畫數據,而不負責動畫的渲染。
2. AnimationController 用于管理 Animation,可以用來設置動畫的時長、啟動動畫、暫停動畫、反轉動畫等。
3. Listener 是 Animation 的回調函數,用來監聽動畫的進度變化,我們需要在這個回調函數中,根據動畫的當前值重新渲染組件,實現動畫的渲染。
接下來,我們看一個具體的案例:讓大屏幕中間的 Flutter Logo 由小變大。
首先,我們初始化了一個動畫周期為 1 秒的、用于管理動畫的 AnimationController 對象,并用線性變化的 Tween 創建了一個變化范圍從 50 到 200 的 Animaiton 對象。
然后,我們給這個 Animaiton 對象設置了一個進度監聽器,并在進度監聽器中強制界面重繪,刷新動畫狀態。
接下來,我們調用 AnimationController 對象的 forward 方法,啟動動畫:
~~~
class _AnimateAppState extends State<AnimateApp> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
@override
void initState() {
super.initState();
// 創建動畫周期為 1 秒的 AnimationController 對象
controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1000));
// 創建從 50 到 200 線性變化的 Animation 對象
animation = Tween(begin: 50.0, end: 200.0).animate(controller)
..addListener(() {
setState(() {}); // 刷新界面
});
controller.forward(); // 啟動動畫
}
...
}
~~~
需要注意的是,我們在創建 AnimationController 的時候,設置了一個 vsync 屬性。這個屬性是用來防止出現不可見動畫的。vsync 對象會把動畫綁定到一個 Widget,當 Widget 不顯示時,動畫將會暫停,當 Widget 再次顯示時,動畫會重新恢復執行,這樣就可以避免動畫的組件不在當前屏幕時白白消耗資源。
我們在一開始提到,Animation 只是用于提供動畫數據,并不負責動畫渲染,所以我們還需要在 Widget 的 build 方法中,把當前動畫狀態的值讀出來,用于設置 Flutter Logo 容器的寬和高,才能最終實現動畫效果:
~~~
@override
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Center(
child: Container(
width: animation.value, // 將動畫的值賦給 widget 的寬高
height: animation.value,
child: FlutterLogo()
)));
}
~~~
最后,別忘了在頁面銷毀時,要釋放動畫資源:
~~~
@override
void dispose() {
controller.dispose(); // 釋放資源
super.dispose();
}
~~~
我們試著運行一下,可以看到,Flutter Logo 動起來了:
:-: 
圖 1 動畫示例
我們在上面用到的 Tween 默認是線性變化的,但可以創建 CurvedAnimation 來實現非線性曲線動畫。CurvedAnimation 提供了很多常用的曲線,比如震蕩曲線 elasticOut:
~~~
// 創建動畫周期為 1 秒的 AnimationController 對象
controller = AnimationController(
vsync: this, duration: const Duration(milliseconds: 1000));
// 創建一條震蕩曲線
final CurvedAnimation curve = CurvedAnimation(
parent: controller, curve: Curves.elasticOut);
// 創建從 50 到 200 跟隨振蕩曲線變化的 Animation 對象
animation = Tween(begin: 50.0, end: 200.0).animate(curve)
~~~
運行一下,可以看到 Flutter Logo 有了一個彈性動畫:
:-: 
圖 2 CurvedAnimation 示例
現在的問題是,這些動畫只能執行一次。如果想讓它像心跳一樣執行,有兩個辦法:
1. 在啟動動畫時,使用 repeat(reverse: true),讓動畫來回重復執行。
2. 監聽動畫狀態。在動畫結束時,反向執行;在動畫反向執行完畢時,重新啟動執行。
具體的實現代碼,如下所示:
~~~
// 以下兩段語句等價
// 第一段
controller.repeat(reverse: true);// 讓動畫重復執行
// 第二段
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();// 動畫結束時反向執行
} else if (status == AnimationStatus.dismissed) {
controller.forward();// 動畫反向執行完畢時,重新執行
}
});
controller.forward();// 啟動動畫
~~~
運行一下,可以看到,我們實現了 Flutter Logo 的心跳效果。
:-: 
圖 3 Flutter Logo 心跳
## AnimatedWidget 與 AnimatedBuilder
在為 Widget 添加動畫效果的過程中我們不難發現,Animation 僅提供動畫的數據,因此我們還需要監聽動畫執行進度,并在回調中使用 setState 強制刷新界面才能看到動畫效果。考慮到這些步驟都是固定的,Flutter 提供了兩個類來幫我們簡化這一步驟,即 AnimatedWidget 與 AnimatedBuilder。
接下來,我們分別看看這兩個類如何使用。
在構建 Widget 時,AnimatedWidget 會將 Animation 的狀態與其子 Widget 的視覺樣式綁定。要使用 AnimatedWidget,我們需要一個繼承自它的新類,并接收 Animation 對象作為其初始化參數。然后,在 build 方法中,讀取出 Animation 對象的當前值,用作初始化 Widget 的樣式。
下面的案例演示了 Flutter Logo 的 AnimatedWidget 版本:用 AnimatedLogo 繼承了 AnimatedWidget,并在 build 方法中,把動畫的值與容器的寬高做了綁定:
~~~
class AnimatedLogo extends AnimatedWidget {
//AnimatedWidget 需要在初始化時傳入 animation 對象
AnimatedLogo({Key key, Animation<double> animation})
: super(key: key, listenable: animation);
Widget build(BuildContext context) {
// 取出動畫對象
final Animation<double> animation = listenable;
return Center(
child: Container(
height: animation.value,// 根據動畫對象的當前狀態更新寬高
width: animation.value,
child: FlutterLogo(),
));
}
}
~~~
在使用時,我們只需把 Animation 對象傳入 AnimatedLogo 即可,再也不用監聽動畫的執行進度刷新 UI 了:\
~~~
MaterialApp(
home: Scaffold(
body: AnimatedLogo(animation: animation)// 初始化 AnimatedWidget 時傳入 animation 對象
));
~~~
在上面的例子中,在 AnimatedLogo 的 build 方法中,我們使用 Animation 的 value 作為 logo 的寬和高。這樣做對于簡單組件的動畫沒有任何問題,但如果動畫的組件比較復雜,一個更好的解決方案是,**將動畫和渲染職責分離**:logo 作為外部參數傳入,只做顯示;而尺寸的變化動畫則由另一個類去管理。
這個分離工作,我們可以借助 AnimatedBuilder 來完成。
與 AnimatedWidget 類似,AnimatedBuilder 也會自動監聽 Animation 對象的變化,并根據需要將該控件樹標記為 dirty 以自動刷新 UI。事實上,如果你翻看[源碼](https://github.com/flutter/flutter/blob/ca5411e3aa99d571ddd80b75b814718c4a94c839/packages/flutter/lib/src/widgets/transitions.dart#L920),就會發現 AnimatedBuilder 其實也是繼承自 AnimatedWidget。
我們以一個例子來演示如何使用 AnimatedBuilder。在這個例子中,AnimatedBuilder 的尺寸變化動畫由 builder 函數管理,渲染則由外部傳入 child 參數負責:
~~~
MaterialApp(
home: Scaffold(
body: Center(
child: AnimatedBuilder(
animation: animation,// 傳入動畫對象
child:FlutterLogo(),
// 動畫構建回調
builder: (context, child) => Container(
width: animation.value,// 使用動畫的當前狀態更新 UI
height: animation.value,
child: child, //child 參數即 FlutterLogo()
)
)
)
));
~~~
可以看到,通過使用 AnimatedWidget 和 AnimatedBuilder,動畫的生成和最終的渲染被分離開了,構建動畫的工作也被大大簡化了。
## hero 動畫
現在我們已經知道了如何在一個頁面上實現動畫效果,那么如何實現在兩個頁面之間切換的過渡動畫呢?比如在社交類 App,在 Feed 流中點擊小圖進入查看大圖頁面的場景中,我們希望能夠實現小圖到大圖頁面逐步放大的動畫切換效果,而當用戶關閉大圖時,也實現原路返回的動畫。
這樣的跨頁面共享的控件動畫效果有一個專門的名詞,即“共享元素變換”(Shared Element Transition)。
對于 Android 開發者來說,這個概念并不陌生。Android 原生提供了對這種動畫效果的支持,通過幾行代碼,就可以實現在兩個 Activity 共享的組件之間做出流暢的轉場動畫。
又比如,Keynote 提供了的“神奇移動”(Magic Move)功能,可以實現兩個 Keynote 頁面之間的流暢過渡。
Flutter 也有類似的概念,即 Hero 控件。**通過 Hero,我們可以在兩個頁面的共享元素之間,做出流暢的頁面切換效果。**
接下來,我們通過一個案例來看看 Hero 組件具體如何使用。
在下面的例子中,我定義了兩個頁面,其中 page1 有一個位于底部的小 Flutter Logo,page2 有一個位于中部的大 Flutter Logo。在點擊了 page1 的小 logo 后,會使用 hero 效果過渡到 page2。
為了實現共享元素變換,我們需要將這兩個組件分別用 Hero 包裹,并同時為它們設置相同的 tag “hero”。然后,為 page1 添加點擊手勢響應,在用戶點擊 logo 時,跳轉到 page2:
~~~
class Page1 extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(// 手勢監聽點擊
child: Hero(
tag: 'hero',// 設置共享 tag
child: Container(
width: 100, height: 100,
child: FlutterLogo())),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(builder: (_)=>Page2()));// 點擊后打開第二個頁面
},
)
);
}
}
class Page2 extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Hero(
tag: 'hero',// 設置共享 tag
child: Container(
width: 300, height: 300,
child: FlutterLogo()
))
);
}
}
~~~
運行一下,可以看到,我們通過簡單的兩步,就可以實現元素跨頁面飛行的復雜動畫效果了!
:-: 
圖 4 Hero 動畫
## 總結
好了,今天的分享就到這里。我們簡單回顧一下今天的主要內容吧。
在 Flutter 中,動畫的狀態與渲染是分離的。我們通過 Animation 生成動畫曲線,使用 AnimationController 控制動畫時間、啟動動畫。而動畫的渲染,則需要設置監聽器獲取動畫進度后,重新觸發組件用新的動畫狀態刷新后才能實現動畫的更新。
為了簡化這一步驟,Flutter 提供了 AnimatedWidget 和 AnimatedBuilder 這兩個組件,省去了狀態監聽和 UI 刷新的工作。而對于跨頁面動畫,Flutter 提供了 Hero 組件,只要兩個相同(相似)的組件有同樣的 tag,就能實現元素跨頁面過渡的轉場效果。
可以看到,Flutter 對于動畫的分層設計還是非常簡單清晰的,但造成的副作用就是使用起來稍微麻煩一些。對于實際應用而言,由于動畫過程涉及到頁面的頻繁刷新,因此我強烈建議你盡量使用 AnimatedWidget 或 AnimatedBuilder 來縮小受動畫影響的組件范圍,只重繪需要做動畫的組件即可,要避免使用進度監聽器直接刷新整個頁面,讓不需要做動畫的組件也跟著一起銷毀重建。
我把今天分享中所涉及的針對控件的普通動畫,AnimatedBuilder 和 AnimatedWidget,以及針對頁面的過渡動畫 Hero 打包到了[GitHub](https://github.com/cyndibaby905/22_app_animation)上,你可以把工程下載下來,多運行幾次,體會這幾種動畫的具體使用方法。
## 思考題
最后,我給你留下兩個小作業吧。
~~~
AnimatedBuilder(
animation: animation,
child:FlutterLogo(),
builder: (context, child) => Container(
width: animation.value,
height: animation.value,
child: child
)
)
~~~
1. 在 AnimatedBuilder 的例子中,child 似乎被指定了兩遍(第 3 行的 child 與第 7 行的 child),你可以解釋下這么做的原因嗎?
2. 如果我把第 3 行的 child 刪掉,把 Flutter Logo 放到第 7 行,動畫是否能正常執行?這會有什么問題嗎?
- 前言
- 開篇詞
- 預習篇
- 01丨預習篇 · 從0開始搭建Flutter工程環境
- 02丨預習篇 · Dart語言概覽
- Flutter開發起步
- 03丨深入理解跨平臺方案的歷史發展邏輯
- 04丨Flutter區別于其他方案的關鍵技術是什么?
- 05丨從標準模板入手,體會Flutter代碼是如何運行在原生系統上的
- Dart語言基礎
- 06丨基礎語法與類型變量:Dart是如何表示信息的?
- 07丨函數、類與運算符:Dart是如何處理信息的?
- 08丨綜合案例:掌握Dart核心特性
- Flutter基礎
- 09丨Widget,構建Flutter界面的基石
- 10丨Widget中的State到底是什么?
- 11丨提到生命周期,我們是在說什么?
- 12丨經典控件(一):文本、圖片和按鈕在Flutter中怎么用?
- 13丨ListView在Flutter中是什么?
- 14 丨 經典布局:如何定義子控件在父容器中排版位置?
- 15 丨 組合與自繪,我該選用何種方式自定義Widget?
- 16 丨 從夜間模式說起,如何定制不同風格的App主題?
- 17丨依賴管理(一):圖片、配置和字體在Flutter中怎么用?
- 18丨依賴管理(二):第三方組件庫在Flutter中要如何管理?
- 19丨用戶交互事件該如何響應?
- 20丨關于跨組件傳遞數據,你只需要記住這三招
- 21丨路由與導航,Flutter是這樣實現頁面切換的
- Flutter進階
- 22丨如何構造炫酷的動畫效果?
- 23丨單線程模型怎么保證UI運行流暢?
- 24丨HTTP網絡編程與JSON解析
- 25丨本地存儲與數據庫的使用和優化
- 26丨如何在Dart層兼容Android-iOS平臺特定實現?(一)
- 27丨如何在Dart層兼容Android-iOS平臺特定實現?(二)
- 28丨如何在原生應用中混編Flutter工程?
- 29丨混合開發,該用何種方案管理導航棧?
- 30丨為什么需要做狀態管理,怎么做?
- 31丨如何實現原生推送能力?
- 32丨適配國際化,除了多語言我們還需要注意什么
- 33丨如何適配不同分辨率的手機屏幕?
- 34丨如何理解Flutter的編譯模式?
- 35丨HotReload是怎么做到的?
- 36丨如何通過工具鏈優化開發調試效率?
- 37丨如何檢測并優化FlutterApp的整體性能表現?
- 38丨如何通過自動化測試提高交付質量?
- Flutter綜合應用
- 39丨線上出現問題,該如何做好異常捕獲與信息采集?
- 40丨衡量FlutterApp線上質量,我們需要關注這三個指標
- 41丨組件化和平臺化,該如何組織合理穩定的Flutter工程結構?
- 42丨如何構建高效的FlutterApp打包發布環境?
- 43丨如何構建自己的Flutter混合開發框架(一)?
- 44丨如何構建自己的Flutter混合開發框架(二)?
- 結束語
- 結束語丨勿畏難,勿輕略