很是感謝 Didier Boelens 贊成我將它的一些文章翻譯爲中文發表,這是其中一篇。git
本文經過一個實例詳細講解了 Flutter 中動畫的原理。github
原文的代碼塊有行號,對修改的代碼有黃色背景。在這裏不能對代碼添加行號和背景顏色,因此,爲方便閱讀有些代碼塊用了截圖。安全
原文 連接bash
Flutter中的動畫功能強大並且使用起來很是簡單。 經過一個具體的例子,您將學習如何構建本身的動畫所需的一切。markdown
難度:中等session
今天咱們沒法想象沒有任何動畫的移動應用。 當您從一個頁面移動到另外一個頁面時,點擊一個按鈕(或InkWell)… 就有一個動畫。 動畫無處不在。app
Flutter使動畫效果很是容易實現。 用很是簡單的話來講,這篇文章討論了這個主題,早些時候這些事只能留給專家,爲了使這篇論文具備吸引力,我採起了用 Flutter 逐步實現如下斷頭臺效果菜單,這個動畫是由 Vitaly Rubtsov 在 Dribble 上發佈的。less
本文的第一部分講解了動畫的理論和主要概念。 第二部分專門用於動畫的實現,就如上面的動圖所顯示的那樣。ide
要有 動畫 效果,須要存在如下3個元素:佈局
一個 Ticker (斷續器)
一個 動做(Animation)
一個 動做控制器 (AnimationController)
簡單來講,Ticker 是一個幾乎定時發送信號的類(大約每秒60次)。 想一想你的手錶每秒鐘都會嘀嗒一聲。 在每一個滴答處,Ticker 調用回調方法,該方法具備自第一個滴答開始後的持續時間。
重要
即便在不一樣時間啓動,全部的 ticher 也將 始終同步。 這對於同步動畫動做很是有用
動畫只不過是一個能夠在動畫的生命週期內改變的值(特定類型)。 這個值在動畫時間內的變化方式能夠是線性的(如1,2,3,4,5 …),也能夠更復雜(參見後面的曲線)。
AnimationController 是一個控制(啓動,中止,重複......)動畫(或幾個動畫)的類。 換句話說,它使用速度(=每秒的值變化率)使動畫值在特定持續時間內從一個低的邊界值(lowerBound) 變爲 一個高的邊界值(upperBound)。
此類可控制動畫。 爲了更精確,我更願說「在一個場景」,由於咱們稍後會看到,幾個不一樣的動畫能夠由同一個控制器控制......
所以,使用此 AnimationController 類,咱們能夠:
向前播放一個場景,反轉
中止一個場景
將場景設置爲某個值
定義場景的邊界值(lowerBound,upperBound)
如下僞代碼顯示了此類的各類不一樣的初始化參數:
AnimationController controller = new AnimationController(
value: // the current value of the animation, usually 0.0 (= default)
lowerBound: // the lowest value of the animation, usually 0.0 (= default)
upperBound: // the highest value of the animation, usually 1.0 (= default)
duration: // the total duration of the whole animation (scene)
vsync: // the ticker provider
debugLabel: // a label to be used to identify the controller
// during debug session
);
複製代碼
大多數時候,初始化 AnimationController 時,value,lowerBound,upperBound和debugLabel都沒有被提到。
爲了起做用,須要將 AnimationController 綁定到 Ticker。
一般地,您將生成一個 Ticker,連接到 Stateful Widget的一個實例。
第2行
告訴 Flutter 你想要一個 新的 單個 Ticker,連接到 MyStateWidget 的這個實例
第8-10行
控制器初始化。 場景 的總持續時間設置爲 1000 毫秒並綁定到 Ticker(vsync:this)。 默認的參數是:lowerBound = 0.0和upperBound = 1.0
第16行
很是重要,您須要在銷燬 MyStateWidget 實例時釋放控制器。
TickerProviderStateMixin 或 SingleTickerProviderStateMixin?
若是您有多個 AnimationController 實例而且想要各自具備不一樣的 Ticker,請將 TickerProviderStateMixin 替換爲 SingleTickerProviderStateMixin。
多虧了每秒約60次的tick,AnimationController在給定的持續時間內線性生成從 lowerBound 到 upperBound 的值。
在這1000毫秒內生成的值的示例:
咱們看到值在1000毫秒內從0.0(lowerBound)變到1.0(upperBound)。 生成了51個不一樣的值。
讓咱們打開代碼以瞭解如何使用它。
第12行
這行告訴控制器每次它的值改變時,咱們須要從新構建 Widget(經過 setState() )
第15行
Widget 初始化完成後,咱們告訴控制器開始計數(forward() -> 從 lowerBound 到 upperBound )
第26行
咱們恢復控制器的值( _controller.value ),而且,在這個例子中,這個值的範圍是0.0到1.0(0%到100%),咱們獲得這個百分比的整數表達式,顯示在屏幕中心。
正如咱們剛剛看到的那樣,控制器返回一系列 十進制值,這些值以 線性 方式變化。 有時,咱們但願:
使用其餘 類型 的值,例如 Offset,int ...
使用不一樣於 0.0 到 1.0 的值範圍
考慮除線性以外的其餘 變化 類型以產生一些效果
爲了可以使用其餘值類型,Animation 類使用 泛型。
換句話說,您能夠定義:
Animation<int> integerVariation;
Animation<double> decimalVariation;
Animation<Offset> offsetVariation;
複製代碼
有時,咱們但願在不一樣於 0.0 和 1.0 的 兩個值之間進行變化。
爲了定義這樣的範圍,咱們將使用 Tween 類。
爲了說明這一點,讓咱們考慮一下你但願角度從 0 到 π/2 弧度變化的狀況。
Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2);
複製代碼
如前所述,將值從 lowerBound 變爲 upperBound 的默認方式是 線性 的,這是控制器的工做方式。
若是要使角度在 0 到 π/2 弧度之間線性變化,請將 Animation 綁定到 AnimationController:
Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(_controller);
複製代碼
當你啓動動畫時(經過 _controller.forward() ),angleAnimation.value 將使用 _controller.value 的值來插入 [0.0; π/2] 的範圍。
下圖顯示了這種線性變化(π/2 = 1.57)
Flutter 提供了一組預約義的變化曲線,列表顯示以下:
使用這些曲線:
Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(
new CurvedAnimation(
parent: _controller,
curve: Curves.ease,
reverseCurve: Curves.easeOut
));
複製代碼
這建立了一個值的變化[0; π/2],這個變化使用一下曲線:
Curves.ease 當動畫從 0.0 -> π/2 (向前)
Curves.easeOut 當動畫從 π/2 -> 0.0 (反轉)
AnimationController 是容許您經過 API 控制動畫的類。(這是最經常使用的 API):
_controller.forward({ double from })
要求控制器開始 lowerBound - > upperBound 的值的變化。可選參數 from 可用於強制控制器從另外一個值開始「計數」而不是 lowerBound
_controller.reverse({ double from })
要求控制器開始 upperBound - > lowerBound 的值變化。可選參數 from 可用於強制控制器從 upperBound 之外的另外一個值開始「計數」
_controller.stop({ bool canceled: true })
中止運行動畫
_controller.reset()
將動畫重置爲 lowerBound
_controller.animateTo(double target, { Duration duration, Curve curve: Curves.linear })
將動畫從其當前值驅動到目標值
_controller.repeat({ double min, double max, Duration period })
開始向前執行動畫,並在完成時重複執行動畫。 若是定義了 min 和 max 值,則 min 和 max 限制重複發生的次數。
因爲動畫可能會意外中止(例如屏幕被退出),所以在使用這些 API 時,添加 「.orCancel」 會更安全:
__controller.forward().orCancel;
複製代碼
因爲這個小技巧,若是在銷燬 _controller 以前取消 Ticker,不會拋出任何異常。
官方文檔中不存在 「場景(scene)」 這個詞,但就我的而言,我發現它更接近現實。 讓我解釋。
正如我所說,一個 AnimationController 管理動畫。 可是,咱們可能會將 「動畫(Animation)」 這個詞理解爲一系列須要按順序或重疊播放的子動畫。關於 如何將子動畫連接在一塊兒的定義,我就稱之爲「場景」。
考慮如下狀況,其中動畫的整個持續時間爲10秒,咱們但願:
前2秒,球從左側移動到屏幕中間
而後,同一個球須要3秒鐘從屏幕的中心移動到頂部中心
最後,球須要5秒淡出。
正如您可能已經想象的那樣,咱們必須考慮 3 種不一樣的動畫:
///
/// _controller 定義,整個持續時間爲10秒
///
AnimationController _controller = new AnimationController(
duration: const Duration(seconds: 10),
vsync: this
);
///
/// 第一個動畫,將球從左側移動到中心
///
Animation<Offset> moveLeftToCenter = new Tween(
begin: new Offset(0.0, screenHeight /2),
end: new Offset(screenWidth /2, screenHeight /2)
).animate(_controller);
///
/// 第二個動畫,將球從中心移動到頂部
///
Animation<Offset> moveCenterToTop = new Tween(
begin: new Offset(screenWidth /2, screenHeight /2),
end: new Offset(screenWidth /2, 0.0)
).animate(_controller);
///
/// 第三個動畫,改變球的不透明度,使其消失
///
Animation<double> disappear = new Tween(
begin: 1.0,
end: 0.0
).animate(_controller);
複製代碼
如今問題是,咱們如何連接(或編排)子動畫?
答案是經過使用 Interval 類來給出的。 但什麼是 間隔(Interval)?
可能與咱們腦殼裏冒出的一個想法相反,一個 間隔 與 時間間隔 無關,而與 一系列值 有關。
若是你考慮 _controller,你必須記住 它是使一個值從 lowerBound 變爲 upperBound。
一般,這兩個值分別保持在 lowerBound = 0.0 和 upperBound = 1.0,這使事情更容易考慮,由於 [0.0 -> 1.0] 只不過是從0%到100%的變化。 所以,若是一個場景的總持續時間是10秒,那麼在5秒以後,相應的_controller.value 將很是接近0.5(= 50%)。
若是咱們在時間軸上放置這 3 個不一樣的動畫,咱們會獲得:
若是咱們如今考慮值的區間,對於3個動畫中的每個,咱們獲得:
從左移動到中心
持續時間:2秒,從0秒開始,以2秒結束=>範圍= [0; 2] =>百分比:從整個場景的0%到20%=> [0.0; 0.20]
從中心移動到頂部
持續時間:3秒,從2秒開始,在第 5 秒結束=>範圍= [2; 5] =>百分比:從整個場景的20%到50%=> [0.20;0.50]
消失
持續時間:5秒,從5秒開始,以10秒結束=>範圍= [5; 10] =>百分比:從整個場景的50%到100%=> [0.50; 1.0]
如今咱們有這些百分比,咱們能夠更新每一個動畫的定義,以下所示:
///
/// _controller 定義,整個持續時間爲10秒
///
AnimationController _controller = new AnimationController(
duration: const Duration(seconds: 10),
vsync: this
);
///
/// 第一個動畫,將球從左側移動到中心
///
Animation<Offset> moveLeftToCenter = new Tween(
begin: new Offset(0.0, screenHeight /2),
end: new Offset(screenWidth /2, screenHeight /2)
).animate(
new CurvedAnimation(
parent: _controller,
curve: new Interval(
0.0,
0.20,
curve: Curves.linear,
),
),
);
///
/// 第二個動畫,將球從中心移動到頂部
///
Animation<Offset> moveCenterToTop = new Tween(
begin: new Offset(screenWidth /2, screenHeight /2),
end: new Offset(screenWidth /2, 0.0)
).animate(
new CurvedAnimation(
parent: _controller,
curve: new Interval(
0.20,
0.50,
curve: Curves.linear,
),
),
);
///
/// 第三個動畫,改變球的不透明度,使其消失
///
Animation<double> disappear = new Tween(begin: 1.0, end: 0.0)
.animate(
new CurvedAnimation(
parent: _controller,
curve: new Interval(
0.50,
1.0,
curve: Curves.linear,
),
),
);
複製代碼
這就是定義場景(或一系列動畫)所需的所有內容。 固然,沒有什麼能阻止你重疊子動畫……
有時,知道動畫(或場景)的狀態頗有用。
動畫有4種不一樣的狀態:
擱置(dismissed):動畫在開始處中止(或還沒有開始)
向前(forward):動畫從開始到結束
反向(reverse):動畫反向運行,從結束到開始
已完成(completed):動畫在結束時中止
要得到這些狀態,咱們須要經過如下方式監聽動畫狀態改變:
myAnimation.addStatusListener((AnimationStatus status){
switch(status){
case AnimationStatus.dismissed:
...
break;
case AnimationStatus.forward:
...
break;
case AnimationStatus.reverse:
...
break;
case AnimationStatus.completed:
...
break;
}
});
複製代碼
一個典型的用法是,若是此狀態是往復切換。 例如,一旦動畫完成,咱們想要反轉它。 爲達到這個效果:
myAnimation.addStatusListener((AnimationStatus status){
switch(status){
///
/// 當動畫開始時,咱們強制播放動畫
///
case AnimationStatus.dismissed:
_controller.forward();
break;
///
/// 當動畫結束時,咱們強制動畫反轉執行
///
case AnimationStatus.completed:
_controller.reverse();
break;
}
});
複製代碼
既然已經介紹了理論,那麼如今是時候實踐了……
正如我在本文開頭所提到的,我如今將經過實現一個名爲 「斷頭臺」 的動畫來實現動畫的概念。
爲了得到這種 斷頭臺 效應,咱們首先須要考慮:
頁面內容自己
菜單欄,當咱們點擊 菜單(或漢堡包)圖標時旋轉
旋轉 進來 時,菜單會重疊頁面內容並填充整個屏幕窗口
一旦菜單徹底可見,咱們再次點擊菜單圖標,菜單就會旋轉 出去,以便回到原來的位置和尺寸
從這些觀察中,咱們能夠當即推斷出咱們不能使用帶有 AppBar 的普通 Scaffold (由於後者是固定的)。
咱們相反地會使用兩層的 Stack:
頁面內容(下層)
菜單(上層)
讓咱們首先構建這個骨架:
class MyPage extends StatefulWidget {
@override
_MyPageState createState() => new _MyPageState();
}
class _MyPageState extends State<MyPage>{
@override
Widget build(BuildContext context){
return SafeArea(
top: false,
bottom: false,
child: new Container(
child: new Stack(
alignment: Alignment.topLeft,
children: <Widget>[
new Page(),
new GuillotineMenu(),
],
),
),
);
}
}
class Page extends StatelessWidget {
@override
Widget build(BuildContext context){
return new Container(
padding: const EdgeInsets.only(top: 90.0),
color: Color(0xff222222),
);
}
}
class GuillotineMenu extends StatefulWidget {
@override
_GuillotineMenuState createState() => new _GuillotineMenuState();
}
class _GuillotineMenuState extends State<GuillotineMenu> {
@overrride
Widget build(BuildContext context){
return new Container(
color: Color(0xff333333),
);
}
}
複製代碼
這段代碼的效果是一個黑屏,只顯示了 GuillotineMenu,覆蓋了整個視口。
若是你仔細看視頻,能夠看到當菜單徹底打開時,它徹底覆蓋了屏幕。 當它剛剛打開時,只能看到像 AppBar 這樣的東西。
沒有什麼能阻止咱們以不一樣的方式看待事物……若是 GuillotineMenu 最初會被旋轉,當咱們點擊菜單按鈕時,咱們將其旋轉π/ 2,以下圖所示?
而後咱們能夠按以下方式重寫 _GuillotineMenuState 類:(沒有給出關於建立佈局方法的解釋,由於這不是本文的目的)
class _GuillotineMenuState extends State<GuillotineMenu> {
double rotationAngle = 0.0;
@override
Widget build(BuildContext context){
MediaQueryData mediaQueryData = MediaQuery.of(context);
double screenWidth = mediaQueryData.size.width;
double screenHeight = mediaQueryData.size.height;
return new Transform.rotate(
angle: rotationAngle,
origin: new Offset(24.0, 56.0),
alignment: Alignment.topLeft,
child: Material(
color: Colors.transparent,
child: Container(
width: screenWidth,
height: screenHeight,
color: Color(0xFF333333),
child: new Stack(
children: <Widget>[
_buildMenuTitle(),
_buildMenuIcon(),
_buildMenuContent(),
],
),
),
),
);
}
///
/// Menu Title
///
Widget _buildMenuTitle(){
return new Positioned(
top: 32.0,
left: 40.0,
width: screenWidth,
height: 24.0,
child: new Transform.rotate(
alignment: Alignment.topLeft,
origin: Offset.zero,
angle: pi / 2.0,
child: new Center(
child: new Container(
width: double.infinity,
height: double.infinity,
child: new Opacity(
opacity: 1.0,
child: new Text('ACTIVITY',
textAlign: TextAlign.center,
style: new TextStyle(
color: Colors.white,
fontSize: 20.0,
fontWeight: FontWeight.bold,
letterSpacing: 2.0,
)),
),
),
)),
);
}
///
/// Menu Icon
///
Widget _buildMenuIcon(){
return new Positioned(
top: 32.0,
left: 4.0,
child: new IconButton(
icon: const Icon(
Icons.menu,
color: Colors.white,
),
onPressed: (){},
),
);
}
///
/// Menu content
///
Widget _buildMenuContent(){
final List<Map> _menus = <Map>[
{
"icon": Icons.person,
"title": "profile",
"color": Colors.white,
},
{
"icon": Icons.view_agenda,
"title": "feed",
"color": Colors.white,
},
{
"icon": Icons.swap_calls,
"title": "activity",
"color": Colors.cyan,
},
{
"icon": Icons.settings,
"title": "settings",
"color": Colors.white,
},
];
return new Padding(
padding: const EdgeInsets.only(left: 64.0, top: 96.0),
child: new Container(
width: double.infinity,
height: double.infinity,
child: new Column(
mainAxisAlignment: MainAxisAlignment.start,
children: _menus.map((menuItem) {
return new ListTile(
leading: new Icon(
menuItem["icon"],
color: menuItem["color"],
),
title: new Text(
menuItem["title"],
style: new TextStyle(
color: menuItem["color"],
fontSize: 24.0),
),
);
}).toList(),
),
),
);
}
}
複製代碼
第10-13行
這幾行定義斷頭臺菜單圍繞一個旋轉中心旋轉,(菜單圖標的位置)
如今這段代碼的結果給出了一個未旋轉的菜單屏幕(由於 rotationAngle = 0.0),它顯示了垂直顯示的標題。
若是更新 rotationAngle 的值(在 -π/2 和 0 之間),您將看到按相應角度旋轉的菜單。
讓咱們作一些動畫
如前所述,咱們須要
一個 SingleTickerProviderStateMixin,由於咱們只有一個場景
一個 AnimationController
具備角度變化的動畫
代碼變成這樣了:
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
AnimationController animationControllerMenu;
Animation<double> animationMenu;
///
/// Menu Icon, onPress() handling
///
_handleMenuOpenClose(){
animationControllerMenu.forward();
}
@override
void initState(){
super.initState();
///
/// Initialization of the animation controller
///
animationControllerMenu = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this
)..addListener((){
setState((){});
});
///
/// Initialization of the menu appearance animation
///
_rotationAnimation = new Tween(
begin: -pi/2.0,
end: 0.0
).animate(animationControllerMenu);
}
@override
void dispose(){
animationControllerMenu.dispose();
super.dispose();
}
@override
Widget build(BuildContext context){
MediaQueryData mediaQueryData = MediaQuery.of(context);
double screenWidth = mediaQueryData.size.width;
double screenHeight = mediaQueryData.size.height;
double angle = animationMenu.value;
return new Transform.rotate(
angle: angle,
origin: new Offset(24.0, 56.0),
alignment: Alignment.topLeft,
child: Material(
color: Colors.transparent,
child: Container(
width: screenWidth,
height: screenHeight,
color: Color(0xFF333333),
child: new Stack(
children: <Widget>[
_buildMenuTitle(),
_buildMenuIcon(),
_buildMenuContent(),
],
),
),
),
);
}
...
///
/// Menu Icon
///
Widget _buildMenuIcon(){
return new Positioned(
top: 32.0,
left: 4.0,
child: new IconButton(
icon: const Icon(
Icons.menu,
color: Colors.white,
),
onPressed: _handleMenuOpenClose,
),
);
}
...
}
複製代碼
OK,當咱們點擊菜單按鈕時,菜單會打開,可是當咱們再次按下按鈕時不會關閉。 這就是AnimationStatus 的做用。
讓咱們添加一個監聽器,並根據 AnimationStatus 決定是向前仍是向後運行動畫。
///
/// Menu animation status
///
enum _GuillotineAnimationStatus { closed, open, animating }
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
AnimationController animationControllerMenu;
Animation<double> animationMenu;
_GuillotineAnimationStatus menuAnimationStatus = _GuillotineAnimationStatus.closed;
_handleMenuOpenClose(){
if (menuAnimationStatus == _GuillotineAnimationStatus.closed){
animationControllerMenu.forward().orCancel;
} else if (menuAnimationStatus == _GuillotineAnimationStatus.open) {
animationControllerMenu.reverse().orCancel;
}
}
@override
void initState(){
super.initState();
///
/// Initialization of the animation controller
///
animationControllerMenu = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this
)..addListener((){
setState((){});
})..addStatusListener((AnimationStatus status) {
if (status == AnimationStatus.completed) {
///
/// When the animation is at the end, the menu is open
///
menuAnimationStatus = _GuillotineAnimationStatus.open;
} else if (status == AnimationStatus.dismissed) {
///
/// When the animation is at the beginning, the menu is closed
///
menuAnimationStatus = _GuillotineAnimationStatus.closed;
} else {
///
/// Otherwise the animation is running
///
menuAnimationStatus = _GuillotineAnimationStatus.animating;
}
});
...
}
...
}
複製代碼
菜單如今按預期打開或關閉,但視頻向咱們顯示了一個開放/關閉動做,它不是線性的,看起來像一個彈跳效果。 讓咱們添加這個效果。
爲此,我將選擇如下兩種效果:
bounceOut 菜單打開時
bounceIn 菜單關閉時
在這個實現中仍然有一些遺漏的東西......打開菜單時標題消失,關閉菜單時又顯示出來了。 這是一個 淡出/淡入 效果,也能夠做爲動畫處理。 咱們加上吧。
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
AnimationController animationControllerMenu;
Animation<double> animationMenu;
Animation<double> animationTitleFadeInOut;
_GuillotineAnimationStatus menuAnimationStatus;
...
@override
void initState(){
...
///
/// Initialization of the menu title fade out/in animation
///
animationTitleFadeInOut = new Tween(
begin: 1.0,
end: 0.0
).animate(new CurvedAnimation(
parent: animationControllerMenu,
curve: new Interval(
0.0,
0.5,
curve: Curves.ease,
),
));
}
...
///
/// Menu Title
///
Widget _buildMenuTitle(){
return new Positioned(
top: 32.0,
left: 40.0,
width: screenWidth,
height: 24.0,
child: new Transform.rotate(
alignment: Alignment.topLeft,
origin: Offset.zero,
angle: pi / 2.0,
child: new Center(
child: new Container(
width: double.infinity,
height: double.infinity,
child: new Opacity(
opacity: animationTitleFadeInOut.value,
child: new Text('ACTIVITY',
textAlign: TextAlign.center,
style: new TextStyle(
color: Colors.white,
fontSize: 20.0,
fontWeight: FontWeight.bold,
letterSpacing: 2.0,
)),
),
),
)),
);
}
...
}
複製代碼
這是我得到的結果,它與原版很是接近,不是嗎?
本文的完整源代碼能夠在 GitHub 上找到。
就像你看到的這樣,構建動畫很是簡單,甚至是複雜的動畫。
我但願這篇很長的文章成功地揭開了 Flutter 動畫的神祕面紗。
請繼續關注下一篇文章,順祝編碼愉快。