本文做者:Didier Boelens
原文連接:www.didierboelens.com/2018/06/ani…
翻譯: hccgit
今天,咱們沒法想象移動應用程序裏面沒有任何動畫,當您從一頁跳轉到另外一頁時,或者點擊一個按鈕(如 InkWell)... 都會有一個動畫。動畫無處不在。github
Flutter 使動畫很是易於實現。安全
簡而言之,這篇文章就是討論這個話題的,儘管以前只有專家才能談論,爲了讓這篇文章顯得更有吸引力,我將挑戰一下,仿照 Vitaly Rubtsov 在 Dribble 上傳的一個 "Guillotine Menu (斬頭菜單)"的一個動畫效果,用 Flutter 一步步的實現這個效果。bash
本文的第一部分將介紹一下主要的理論知識和概念,第二部分將要實現上面的那個動畫效果。session
爲了可以實現動畫效果,必須提供下面的三個元素:app
下面對這幾個元素進行一下簡單的介紹,更詳細的在後面說明。框架
簡單來講,Ticker 這個類會在常規的一個時間區間裏(大約每秒 60 次),發送一個信號,把這想象成你得手錶,每秒都會滴答滴答的轉。less
當 Ticker 啓動以後,自從第一個 tick 到來開始,每一個到的 tick 都會回調 Ticker 的 callback 方法。ide
重要提示
儘管全部的 ticker 多是在不一樣的時間裏啓動的,可是它們老是以同步的方式執行,這對於一些同步動畫是頗有用的。佈局
Animation 其實沒有什麼特別的,只不過是一個能夠隨着動畫的生命週期改變的一個值(有特定的類型),值隨着動畫時間的變化而變化的方式能夠是線性的(例如一、二、三、四、5...),也能夠更爲複雜(參考後面的「Curves 曲線」)。
AnimationController 是一個能夠控制一個或多個動畫(開始,結束,重複)的控制器。換句話說,它讓上面說的 Animation 值在一個指定的時間內,根據一個速度從一個最小值變化到最大。
此類可控制動畫。爲了更加精確,我寧願說「 控制一個場景」,由於稍後咱們將看到,幾個不一樣的動畫能夠由同一個控制器來控制……
所以,使用這個AnimationController類,咱們能夠:
如下僞代碼能夠展現這個類裏面的不一樣的初始化參數
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 綁定到一個 StatefulWidget 實例上。
class _MyStateWidget extends State<MyStateWidget>
with SingleTickerProviderStateMixin {
AnimationController _controller;
@override
void initState(){
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
}
@override
void dispose(){
_controller.dispose();
super.dispose();
}
...
}
複製代碼
第 2 行 這行代碼告訴 Flutter ,你想要一個單 Ticker,這個 Ticker 連接到了 MyStateWidget 實例上。
8-10行
控制器的初始化。場景(子動畫)的總持續時間設置爲1000毫秒,並綁定到了 Ticker(vsync:this)。
隱式參數爲:lowerBound = 0.0 和 upperBound = 1.0
很是重要,當 MyStateWidget 這個頁面的實例銷燬時,您須要釋放 controller。
TickerProviderStateMixin 仍是 SingleTickerProviderStateMixin?
若是你有幾個Animation Controller狀況下,你想有不一樣的 Ticker, 只須要將 SingleTickerProviderStateMixin 替換爲 TickerProviderStateMixin。
正是因爲 ticker,每秒鐘將會產生大約 60 個 tick,AnimationController 將根據 tick 在給定的時間裏,線性的產生在最小值和最大值之間的值。
在這1000毫秒內產生的值的示例以下:
咱們看到值在1000毫秒內從0.0(lowerBound)到1.0(upperBound)變化。生成了51個不一樣的值。
讓咱們擴展代碼以查看如何使用它。
class _MyStateWidget extends State<MyStateWidget>
with SingleTickerProviderStateMixin {
AnimationController _controller;
@override
void initState(){
super.initState();
_controller = new AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
);
_controller.addListener((){
setState((){});
});
_controller.forward();
}
@override
void dispose(){
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context){
final int percent = (_controller.value * 100.0).round();
return new Scaffold(
body: new Container(
child: new Center(
child: new Text('$percent%'),
),
),
);
}
}
複製代碼
12 行 此行告訴控制器,每次其值更改時,咱們都須要重建Widget(經過setState())
第15行
Widget初始化完成後,咱們告訴控制器開始計數(forward() -> 從lowerBound到upperBound)
咱們檢索控制器的值(_controller.value),而且在此示例中,此值的範圍是0.0到1.0(也就是 0% 到 100%),咱們獲得此百分比的整數表達式,將其顯示在頁面的中心。
如咱們所見, controller 能夠以線性的方式返回彼此不一樣的小數值。
有的時候咱們可能還有其餘的需求如:
爲了可以使用其餘值類型,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 的默認方式是線性的,controller 就是這麼控制的。
若是要使角度從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 提供了一組預約義的 Curved 變化,以下:
要使用這些曲線效果:
Animation<double> angleAnimation = new Tween(begin: 0.0, end: pi/2).animate(
new CurvedAnimation(
parent: _controller,
curve: Curves.ease,
reverseCurve: Curves.easeOut
));
複製代碼
這將產生值[0; π/ 2] 之間的值:
該AnimationController 類可讓你經過 API 來控制動畫。(如下是最經常使用的API):
要求控制器開始生成 lowerBound- > upperBound中的值
from 的可選參數可用於強制控制器從lowerBound以外的另外一個值開始「 計數 」
要求控制器開始生成 upperBound- > lowerBound中的值
from的可選參數可用於強制控制器從「 upperBound 」以外的另外一個值開始「 計數 」
中止運行動畫
將動畫重置爲從 LowerBound 開始
將動畫的當前值改變到目標值。
開始以正向運行動畫,並在動畫完成後從新啓動動畫。若是定義了 min 或者 max ,將限制動畫的重複執行次數。
因爲動畫可能會意外中止(例如關閉屏幕),所以在使用如下API之一時,添加「 .orCancel 」 更爲安全:
__controller.forward().orCancel;
複製代碼
這個小技巧,能夠保證,在 _controller 釋放以前,若是 Ticker 取消了,將不會致使異常。
官方文檔中不存在「 場景 」一詞,但就我我的而言,我發現它更接近現實。我來解釋一下。
如我所說,一個 AnimationController 管理一個Animation。可是,咱們可能將「 動畫 」 一詞理解爲一系列須要依次播放或重疊播放的子動畫。將子動畫組合在一塊兒,這就是我所說的「 場景 」。
考慮如下狀況,其中動畫的整個持續時間爲10秒,咱們但願達到的效果是:
///
/// Definition of the _controller with a whole duration of 10 seconds
///
AnimationController _controller = new AnimationController(
duration: const Duration(seconds: 10),
vsync: this
);
///
/// First animation that moves the ball from the left to the center
///
Animation<Offset> moveLeftToCenter = new Tween(
begin: new Offset(0.0, screenHeight /2),
end: new Offset(screenWidth /2, screenHeight /2)
).animate(_controller);
///
/// Second animation that moves the ball from the center to the top
///
Animation<Offset> moveCenterToTop = new Tween(
begin: new Offset(screenWidth /2, screenHeight /2),
end: new Offset(screenWidth /2, 0.0)
).animate(_controller);
///
/// Third animation that will be used to change the opacity of the ball to make it disappear
///
Animation<double> disappear = new Tween(
begin: 1.0,
end: 0.0
).animate(_controller);
複製代碼
如今的問題是,咱們如何連接(或編排)子動畫?
組合動畫能夠經過 Interval 這個類來實現。可是,那什麼是 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]
如今咱們有了這些百分比,咱們獲得每一個動畫的定義,以下:
///
/// Definition of the _controller with a whole duration of 10 seconds
///
AnimationController _controller = new AnimationController(
duration: const Duration(seconds: 10),
vsync: this
);
///
/// First animation that moves the ball from the left to the center
///
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,
),
),
);
///
/// Second animation that moves the ball from the center to the top
///
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,
),
),
);
///
/// Third animation that will be used to change the opacity of the ball to make it disappear
///
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種不一樣的狀態:
要得到此狀態,咱們須要經過如下方式監聽動畫狀態的變化:
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){
///
/// When the animation is at the beginning, we force the animation to play
///
case AnimationStatus.dismissed:
_controller.forward();
break;
///
/// When the animation is at the end, we force the animation to reverse
///
case AnimationStatus.completed:
_controller.reverse();
break;
}
});
複製代碼
我在文章開頭提到了一個動畫,如今我準備開始實現它,名字就叫「guillotine(斷頭臺)」
將來可以實現「斬頭臺」效果,咱們須要考慮一下幾個方面:
從這些觀察中,咱們能夠當即得出結論,咱們沒有使用帶有AppBar的普通Scaffold(由於後者是固定的)。
咱們須要使用 2 層 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。
若是你看了上面的示例,能夠看到菜單徹底打開時,它徹底覆蓋了視口。打開後,只有可見的AppBa。
而若是最初旋轉 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(),
),
),
);
}
}
複製代碼
這些線定義了斷頭臺菜單圍繞旋轉中心(菜單圖標的位置)的旋轉
如今,此代碼的結果將顯示一個未旋轉的菜單屏幕(由於rotationAngle = 0.0),該屏幕顯示了垂直的標題。
若是更新 rotationAngle 的值(在-π/ 2和0之間),您將看到菜單旋轉了相應的角度。
如前所述,咱們須要
代碼以下所示:
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,
),
);
}
...
}
複製代碼
如今,當咱們按下菜單按鈕時,菜單會打開,但再次按下按鈕時菜單不會關閉。這是 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;
}
});
...
}
...
}
複製代碼
如今菜單能夠按預期方式打開或關閉,可是前面的演示向咱們展現了一個打開/關閉的動畫,該懂哈不是線性的,看起來有一個反覆的回彈效果。接下來讓咱們添加此效果。
爲此,我將選擇如下2種效果:
class _GuillotineMenuState extends State<GuillotineMenu>
with SingleTickerProviderStateMixin {
...
@override
void initState(){
...
///
/// Initialization of the menu appearance animation
///
animationMenu = new Tween(
begin: -pi / 2.0,
end: 0.0
).animate(new CurvedAnimation(
parent: animationControllerMenu,
curve: Curves.bounceOut,
reverseCurve: Curves.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 中的動畫。
請繼續關注個人下一篇文章,同時,編碼愉快!。