非常感謝 Didier Boelens 同意我將它的一些文章翻譯為中文發表,這是其中一篇。
本文通過一個例項詳細講解了 Flutter 中動畫的原理。
原文的程式碼塊有行號,對修改的程式碼有黃色背景。在這裡不能對程式碼新增行號和背景顏色,所以,為方便閱讀有些程式碼塊用了截圖。
原文 連結
Flutter中的動畫功能強大而且使用起來非常簡單。 通過一個具體的例子,您將學習如何構建自己的動畫所需的一切。
難度:中等
今天我們無法想象沒有任何動畫的移動應用。 當您從一個頁面移動到另一個頁面時,點選一個按鈕(或InkWell)… 就有一個動畫。 動畫無處不在。
Flutter使動畫效果非常容易實現。 用非常簡單的話來說,這篇文章討論了這個主題,早些時候這些事只能留給專家,為了使這篇論文具有吸引力,我採取了用 Flutter 逐步實現以下斷頭臺效果選單,這個動畫是由 Vitaly Rubtsov 在 Dribble 上釋出的。
本文的第一部分講解了動畫的理論和主要概念。 第二部分專門用於動畫的實現,就如上面的動圖所顯示的那樣。
動畫的 3 個支柱
要有 動畫 效果,需要存在以下3個元素:
-
一個 Ticker (斷續器)
-
一個 動作(Animation)
-
一個 動作控制器 (AnimationController)
斷續器 (Ticker)
簡單來說,Ticker 是一個幾乎定時傳送訊號的類(大約每秒60次)。 想想你的手錶每秒鐘都會嘀嗒一聲。 在每個滴答處,Ticker 呼叫回撥方法,該方法具有自第一個滴答開始後的持續時間。
重要
即使在不同時間啟動,所有的 ticher 也將 始終同步。 這對於同步動畫動作非常有用
動畫
動畫只不過是一個可以在動畫的生命週期內改變的值(特定型別)。 這個值在動畫時間內的變化方式可以是線性的(如1,2,3,4,5 …),也可以更復雜(參見後面的曲線)。
動畫控制器
AnimationController 是一個控制(啟動,停止,重複......)動畫(或幾個動畫)的類。 換句話說,它使用速度(=每秒的值變化率)使動畫值在特定持續時間內從一個低的邊界值(lowerBound) 變為 一個高的邊界值(upperBound)。
AnimationController 類
此類可控制動畫。 為了更精確,我更願說“在一個場景”,因為我們稍後會看到,幾個不同的動畫可以由同一個控制器控制......
因此,使用此 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?
為了起作用,需要將 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。
OK,我將控制器繫結到 Ticker 但是它有什麼幫助?
多虧了每秒約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 預定義的 變化曲線
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 類來給出的。 但什麼是 間隔(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 動畫的神祕面紗。
請繼續關注下一篇文章,順祝編碼愉快。