Flutter 動畫簡易教程

Flutter程式設計開發發表於2019-12-22

本文作者:Didier Boelens
原文連結:www.didierboelens.com/2018/06/ani…
翻譯: hcc


Flutter中的動畫功能強大且易於使用。接下來通過一個具體的例項,您將學到關於 Flutter 動畫的一切。

難度:中級

今天,我們無法想象移動應用程式裡面沒有任何動畫,當您從一頁跳轉到另一頁時,或者點選一個按鈕(如 InkWell)... 都會有一個動畫。動畫無處不在。

Flutter 使動畫非常易於實現。

簡而言之,這篇文章就是討論這個話題的,儘管之前只有專家才能談論,為了讓這篇文章顯得更有吸引力,我將挑戰一下,仿照 Vitaly Rubtsov 在 Dribble 上傳的一個 "Guillotine Menu (斬頭選單)"的一個動畫效果,用 Flutter 一步步的實現這個效果。

Flutter 動畫簡易教程

本文的第一部分將介紹一下主要的理論知識和概念,第二部分將要實現上面的那個動畫效果。

動畫中的三大核心

為了能夠實現動畫效果,必須提供下面的三個元素:

  • Ticker
  • Animation
  • AnimationController

下面對這幾個元素進行一下簡單的介紹,更詳細的在後面說明。

Ticker

簡單來說,Ticker 這個類會在常規的一個時間區間裡(大約每秒 60 次),傳送一個訊號,把這想象成你得手錶,每秒都會滴答滴答的轉。

當 Ticker 啟動之後,自從第一個 tick 到來開始,每個到的 tick 都會回撥 Ticker 的 callback 方法。

重要提示
儘管所有的 ticker 可能是在不同的時間裡啟動的,但是它們總是以同步的方式執行,這對於一些同步動畫是很有用的。

Animation

Animation 其實沒有什麼特別的,只不過是一個可以隨著動畫的生命週期改變的一個值(有特定的型別),值隨著動畫時間的變化而變化的方式可以是線性的(例如1、2、3、4、5...),也可以更為複雜(參考後面的“Curves 曲線”)。

AnimationController

AnimationController 是一個可以控制一個或多個動畫(開始,結束,重複)的控制器。換句話說,它讓上面說的 Animation 值在一個指定的時間內,根據一個速度從一個最小值變化到最大。

AnimationController 類介紹

此類可控制動畫。為了更加精確,我寧願說“ 控制一個場景”,因為稍後我們將看到,幾個不同的動畫可以由同一個控制器來控制……

因此,使用這個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 上

為了讓動畫正常工作,必須將 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

  • 16行

非常重要,當 MyStateWidget 這個頁面的例項銷燬時,您需要釋放 controller。

TickerProviderStateMixin 還是 SingleTickerProviderStateMixin?

如果你有幾個Animation Controller情況下,你想有不同的 Ticker, 只需要將 SingleTickerProviderStateMixin 替換為 TickerProviderStateMixin。

好的,我已經將控制器繫結到了 Ticker 上,但是它是工作的?

正是由於 ticker,每秒鐘將會產生大約 60 個 tick,AnimationController 將根據 tick 在給定的時間裡,線性的產生在最小值和最大值之間的值。

在這1000毫秒內產生的值的示例如下:

Flutter 動畫簡易教程

我們看到值在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)

  • 26行

我們檢索控制器的值(_controller.value),並且在此示例中,此值的範圍是0.0到1.0(也就是 0% 到 100%),我們得到此百分比的整數表示式,將其顯示在頁面的中心。

動畫的概念

如我們所見, controller 可以以線性的方式返回彼此不同的小數值。

有的時候我們可能還有其他的需求如:

  • 使用其他型別的值,例如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 的預設方式是線性的,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 動畫簡易教程

使用Flutter預定義的曲線變化

Flutter 提供了一組預定義的 Curved 變化,如下:

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] 之間的值:

  • 當正向播放動畫,數值從 0 到 π/2 ,會使用 Curves.ease 效果
  • 當反向播放動畫,數值從 π/2 到 0,會使用 Curves.easeOut 效果

控制動畫

該AnimationController 類可以讓你通過 API 來控制動畫。(以下是最常用的API):

  • _controller.forward({兩個區間的值})

要求控制器開始生成 lowerBound- > upperBound中的值

from 的可選引數可用於強制控制器從lowerBound之外的另一個值開始“ 計數 ”

  • _controller.reverse({兩個區間的值})

要求控制器開始生成 upperBound- > lowerBound中的值

from的可選引數可用於強制控制器從“ upperBound ”之外的另一個值開始“ 計數 ”

  • _controller.stop({bool cancelled:true})

停止執行動畫

  • _controller.reset()

將動畫重置為從 LowerBound 開始

  • _controller.animateTo(double target, { Duration duration, Curve curve: Curves.linear })

將動畫的當前值改變到目標值。

  • _controller.repeat({double min,double max,Duration period})

開始以正向執行動畫,並在動畫完成後重新啟動動畫。如果定義了 min 或者 max ,將限制動畫的重複執行次數。

安全起見

由於動畫可能會意外停止(例如關閉螢幕),因此在使用以下API之一時,新增“ .orCancel ” 更為安全:

__controller.forward().orCancel;
複製程式碼

這個小技巧,可以保證,在 _controller 釋放之前,如果 Ticker 取消了,將不會導致異常。

場景的概念

官方文件中不存在“ 場景 ”一詞,但就我個人而言,我發現它更接近現實。我來解釋一下。

如我所說,一個 AnimationController 管理一個Animation。但是,我們可能將“ 動畫 ” 一詞理解為一系列需要依次播放或重疊播放的子動畫。將子動畫組合在一起,這就是我所說的“ 場景 ”。

考慮以下情況,其中動畫的整個持續時間為10秒,我們希望達到的效果是:

  • 在開始的2秒內,有一個球從螢幕的左側移動到螢幕的中間
  • 然後,同一個球需要3秒鐘才能從螢幕中心移動到螢幕頂部中心
  • 最終,球需要5秒鐘才能消失。 正如您最可能已經想到的那樣,我們必須考慮3種不同的動畫:

///
/// 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?

可能和我們腦子裡首先想到的不一樣, Interval 和時間沒有關係,而是一組值的範圍。

如果考慮使用 _controller,則必須記住,它會使值從 lowerBound 到 upperBound 變化。

通常,這兩個值基本定義為 lowerBound = 0.0 和 upperBound = 1.0,這使動畫計算更容易,因為[0.0-> 1.0]只是從0%到100%的變化。因此,如果一個場景的總持續時間為10秒,則最有可能在5秒後,相應的_controller.value將非常接近0.5(= 50%)。

如果將3個不同的動畫放在一個時間軸上,則可以獲得如下示意圖:

Flutter 動畫簡易教程

如果現在考慮值的間隔,則對於3個動畫中的每個動畫,我們將得到:

  • moveLeftToCenter

持續時間:2秒,從0秒開始,以2秒結束=>範圍= [0; 2] =>百分比:從整個場景的0%到20%=> [0.0; 0.20]

  • moveCenterToTop

持續時間:3秒,開始於2秒,結束於5秒=>範圍= [2; 5] =>百分比:從整個場景的20%到50%=> [0.20; 0.50]

  • disappear

持續時間: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種不同的狀態:

  • 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){
          ///
          /// 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,將會怎樣呢,如下圖所示這樣嗎?

Flutter 動畫簡易教程

然後,我們可以按以下方式重寫_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),該螢幕顯示了垂直的標題。

接下來使 menu 顯示動畫

如果更新 rotationAngle 的值(在-π/ 2和0之間),您將看到選單旋轉了相應的角度。

如前所述,我們需要

  • 一個SingleTickerProviderStateMixin,因為我們只有1個場景
  • 一個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,
			),
		);
	}
	...
}
複製程式碼

現在,當我們按下選單按鈕時,選單會開啟,但再次按下按鈕時選單不會關閉。這是 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種效果:

  • 選單開啟時用 bounceOut
  • 選單關閉時用 bouncIn

Flutter 動畫簡易教程

Flutter 動畫簡易教程


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,
					)),
				),
			),
		)),
	);
  }
...
}
複製程式碼

最終的效果基本如下:

Flutter 動畫簡易教程

本文的完整原始碼可在GitHub上找到。

結論

如您所見,構建動畫非常簡單,甚至複雜的動畫也是如此。

我希望這篇較長的文章能夠成功的解釋 Flutter 中的動畫。

請繼續關注我的下一篇文章,同時,編碼愉快!。

Flutter 動畫簡易教程

相關文章