對於一個前端的App來說,新增適當的動畫,可以給使用者更好的體驗和視覺效果。所以無論是原生的iOS或Android,還是前端開發中都會提供完成某些動畫的API。
Flutter有自己的渲染閉環,我們當然可以給它提供一定的資料模型,來讓它幫助我們實現對應的動畫效果。
一. 動畫API認識
動畫其實是我們通過某些方式(比如物件,Animation物件)給Flutter引擎提供不同的值,而Flutter可以根據我們提供的值,給對應的Widget新增順滑的動畫效果。
針對動畫這個章節,我打算先理清楚他們的API關係和作用,再來講解如何利用這些API來實現不同的動畫效果。
1.1. Animation
在Flutter中,實現動畫的核心類是Animation,Widget可以直接將這些動畫合併到自己的build方法中來讀取它們的當前值或者監聽它們的狀態變化。
我們一起來看一下Animation這個類,它是一個抽象類:
addListener方法 每當動畫的狀態值發生變化時,動畫都會通知所有通過 addListener
新增的監聽器。通常,一個正在監聽動畫的 state
物件會呼叫自身的setState
方法,將自身傳入這些監聽器的回撥函式來通知 widget 系統需要根據新狀態值進行重新構建。
addStatusListener 當動畫的狀態發生變化時,會通知所有通過 addStatusListener
新增的監聽器。通常情況下,動畫會從 dismissed
狀態開始,表示它處於變化區間的開始點。舉例來說,從 0.0 到 1.0 的動畫在 dismissed
狀態時的值應該是 0.0。動畫進行的下一狀態可能是 forward
(比如從 0.0 到 1.0)或者reverse
(比如從 1.0 到 0.0)。最終,如果動畫到達其區間的結束點(比如 1.0),則動畫會變成 completed
狀態。
abstract class Animation<T> extends Listenable implements ValueListenable<T> {
const Animation();
// 新增動畫監聽器
@override
void addListener(VoidCallback listener);
// 移除動畫監聽器
@override
void removeListener(VoidCallback listener);
// 新增動畫狀態監聽器
void addStatusListener(AnimationStatusListener listener);
// 移除動畫狀態監聽器
void removeStatusListener(AnimationStatusListener listener);
// 獲取動畫當前狀態
AnimationStatus get status;
// 獲取動畫當前的值
@override
T get value;
複製程式碼
1.2. AnimationController
Animation是一個抽象類,並不能用來直接建立物件實現動畫的使用。
AnimationController是Animation的一個子類,實現動畫通常我們需要建立AnimationController物件。
AnimationController會生成一系列的值,預設情況下值是0.0到1.0區間的值;
除了上面的監聽,獲取動畫的狀態、值之外,AnimationController還提供了對動畫的控制:
forward:向前執行動畫 reverse:方向播放動畫 stop:停止動畫
AnimationController的原始碼:
class AnimationController extends Animation<double>
with AnimationEagerListenerMixin, AnimationLocalListenersMixin, AnimationLocalStatusListenersMixin {
AnimationController({
// 初始化值
double value,
// 動畫執行的時間
this.duration,
// 反向動畫執行的時間
this.reverseDuration,
// 最小值
this.lowerBound = 0.0,
// 最大值
this.upperBound = 1.0,
// 重新整理率ticker的回撥(看下面詳細解析)
@required TickerProvider vsync,
})
}
複製程式碼
AnimationController有一個必傳的引數vsync,它是什麼呢?
之前我講過關於Flutter的渲染閉環,Flutter每次渲染一幀畫面之前都需要等待一個vsync訊號。 這裡也是為了監聽vsync訊號,當Flutter開發的應用程式不再接受同步訊號時(比如鎖屏或退到後臺),那麼繼續執行動畫會消耗效能。 這個時候我們設定了Ticker,就不會再出發動畫了。 開發中比較常見的是將SingleTickerProviderStateMixin混入到State的定義中。
1.3. CurvedAnimation
CurvedAnimation也是Animation的一個實現類,它的目的是為了給AnimationController增加動畫曲線:
CurvedAnimation可以將AnimationController和Curve結合起來,生成一個新的Animation物件
class CurvedAnimation extends Animation<double> with AnimationWithParentMixin<double> {
CurvedAnimation({
// 通常傳入一個AnimationController
@required this.parent,
// Curve型別的物件
@required this.curve,
this.reverseCurve,
});
}
複製程式碼
Curve型別的物件的有一些常量Curves(和Color型別有一些Colors是一樣的),可以供我們直接使用:
對值的效果,可以直接檢視官網(有對應的gif效果,一目瞭然) https://api.flutter.dev/flutter/animation/Curves-class.html
官方也給出了自己定義Curse的一個示例:
import 'dart:math';
class ShakeCurve extends Curve {
@override
double transform(double t) => sin(t * pi * 2);
}
複製程式碼
1.4. Tween
預設情況下,AnimationController動畫生成的值所在區間是0.0到1.0
如果希望使用這個以外的值,或者其他的資料型別,就需要使用Tween
Tween的原始碼:
原始碼非常簡單,傳入兩個值即可,可以定義一個範圍。
class Tween<T extends dynamic> extends Animatable<T> {
Tween({ this.begin, this.end });
}
複製程式碼
Tween也有一些子類,比如ColorTween、BorderTween,可以針對動畫或者邊框來設定動畫的值。
Tween.animate
要使用Tween物件,需要呼叫Tween的animate()方法,傳入一個Animation物件。
二. 動畫案例練習
2.1. 動畫的基本使用
我們來完成一個案例:
點選案例後執行一個心跳動畫,可以反覆執行 再次點選可以暫停和重新開始動畫
class HYHomePage extends StatelessWidget {
final GlobalKey<_AnimationDemo01State> demo01Key = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("動畫測試"),
),
body: AnimationDemo01(key: demo01Key),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.play_circle_filled),
onPressed: () {
if (!demo01Key.currentState.controller.isAnimating) {
demo01Key.currentState.controller.forward();
} else {
demo01Key.currentState.controller.stop();
}
},
),
);
}
}
class AnimationDemo01 extends StatefulWidget {
AnimationDemo01({Key key}): super(key: key);
@override
_AnimationDemo01State createState() => _AnimationDemo01State();
}
class _AnimationDemo01State extends State<AnimationDemo01> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
@override
void initState() {
super.initState();
// 1.建立AnimationController
controller = AnimationController(duration: Duration(seconds: 1), vsync: this);
// 2.動畫新增Curve效果
animation = CurvedAnimation(parent: controller, curve: Curves.elasticInOut, reverseCurve: Curves.easeOut);
// 3.監聽動畫
animation.addListener(() {
setState(() {});
});
// 4.控制動畫的翻轉
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});
// 5.設定值的範圍
animation = Tween(begin: 50.0, end: 120.0).animate(controller);
}
@override
Widget build(BuildContext context) {
return Center(
child: Icon(Icons.favorite, color: Colors.red, size: animation.value,),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
複製程式碼
2.2. AnimatedWidget
在上面的程式碼中,我們必須監聽動畫值的改變,並且改變後需要呼叫setState,這會帶來兩個問題:
1.執行動畫必須包含被部分程式碼,程式碼比較冗餘 2.呼叫setState意味著整個State類中的build方法就會被重新build
如何可以優化上面的操作呢?AnimatedWidget
建立一個Widget繼承自AnimatedWidget:
class IconAnimation extends AnimatedWidget {
IconAnimation(Animation animation): super(listenable: animation);
@override
Widget build(BuildContext context) {
Animation animation = listenable;
return Icon(Icons.favorite, color: Colors.red, size: animation.value,);
}
}
複製程式碼
修改_AnimationDemo01State中的程式碼:
class _AnimationDemo01State extends State<AnimationDemo01> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
@override
void initState() {
super.initState();
// 1.建立AnimationController
controller = AnimationController(duration: Duration(seconds: 1), vsync: this);
// 2.動畫新增Curve效果
animation = CurvedAnimation(parent: controller, curve: Curves.elasticInOut, reverseCurve: Curves.easeOut);
// 3.監聽動畫
// 4.控制動畫的翻轉
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});
// 5.設定值的範圍
animation = Tween(begin: 50.0, end: 120.0).animate(controller);
}
@override
Widget build(BuildContext context) {
return Center(
child: IconAnimation(animation),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
複製程式碼
2.3. AnimatedBuilder
Animated是不是最佳的解決方案呢?
1.我們每次都要新建一個類來繼承自AnimatedWidget
2.如果我們的動畫Widget有子Widget,那麼意味著它的子Widget也會重新build
如何可以優化上面的操作呢?AnimatedBuilder
class _AnimationDemo01State extends State<AnimationDemo01> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
@override
void initState() {
super.initState();
// 1.建立AnimationController
controller = AnimationController(duration: Duration(seconds: 1), vsync: this);
// 2.動畫新增Curve效果
animation = CurvedAnimation(parent: controller, curve: Curves.elasticInOut, reverseCurve: Curves.easeOut);
// 3.監聽動畫
// 4.控制動畫的翻轉
animation.addStatusListener((status) {
if (status == AnimationStatus.completed) {
controller.reverse();
} else if (status == AnimationStatus.dismissed) {
controller.forward();
}
});
// 5.設定值的範圍
animation = Tween(begin: 50.0, end: 120.0).animate(controller);
}
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: animation,
builder: (ctx, child) {
return Icon(Icons.favorite, color: Colors.red, size: animation.value,);
},
),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
複製程式碼
三. 其它動畫補充
3.1. 交織動畫
案例說明:
點選floatingActionButton執行動畫 動畫集合了透明度變化、大小變化、顏色變化、旋轉動畫等; 我們這裡是通過多個Tween生成了多個Animation物件;
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue, splashColor: Colors.transparent),
home: HYHomePage(),
);
}
}
class HYHomePage extends StatelessWidget {
final GlobalKey<_AnimationDemo01State> demo01Key = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("列表測試"),
),
body: AnimationDemo01(key: demo01Key),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.play_circle_filled),
onPressed: () {
demo01Key.currentState.controller.forward();
},
),
);
}
}
class AnimationDemo01 extends StatefulWidget {
AnimationDemo01({Key key}): super(key: key);
@override
_AnimationDemo01State createState() => _AnimationDemo01State();
}
class _AnimationDemo01State extends State<AnimationDemo01> with SingleTickerProviderStateMixin {
AnimationController controller;
Animation<double> animation;
Animation<Color> colorAnim;
Animation<double> sizeAnim;
Animation<double> rotationAnim;
@override
void initState() {
super.initState();
// 1.建立AnimationController
controller = AnimationController(duration: Duration(seconds: 2), vsync: this);
// 2.動畫新增Curve效果
animation = CurvedAnimation(parent: controller, curve: Curves.easeIn);
// 3.監聽動畫
animation.addListener(() {
setState(() {});
});
// 4.設定值的變化
colorAnim = ColorTween(begin: Colors.blue, end: Colors.red).animate(controller);
sizeAnim = Tween(begin: 0.0, end: 200.0).animate(controller);
rotationAnim = Tween(begin: 0.0, end: 2*pi).animate(controller);
}
@override
Widget build(BuildContext context) {
return Center(
child: Opacity(
opacity: animation.value,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationZ(animation.value),
child: Container(
width: sizeAnim.value,
height: sizeAnim.value,
color: colorAnim.value,
alignment: Alignment.center,
),
),
),
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
}
複製程式碼
當然,我們可以使用Builder來對程式碼進行優化
@override
Widget build(BuildContext context) {
return Center(
child: AnimatedBuilder(
animation: controller,
builder: (ctx, child) {
return Opacity(
opacity: animation.value,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationZ(rotationAnim.value),
child: Container(
width: sizeAnim.value,
height: sizeAnim.value,
color: colorAnim.value,
alignment: Alignment.center,
),
),
);
},
),
);
}
複製程式碼
3.2. Hero動畫
移動端開發會經常遇到類似這樣的需求:
點選一個頭像,顯示頭像的大圖,並且從原來影像的Rect到大圖的Rect 點選一個商品的圖片,可以展示商品的大圖,並且從原來影像的Rect到大圖的Rect
這種跨頁面共享的動畫被稱之為享元動畫(Shared Element Transition)
在Flutter中,有一個專門的Widget可以來實現這種動畫效果:Hero
實現Hero動畫,需要如下步驟:
1.在第一個Page1中,定義一個起始的Hero Widget,被稱之為source hero,並且繫結一個tag; 2.在第二個Page2中,定義一個終點的Hero Widget,被稱之為 destination hero,並且繫結相同的tag; 3.可以通過Navigator來實現第一個頁面Page1到第二個頁面Page2的跳轉過程;
Flutter會設定Tween來界定Hero從起點到終端的大小和位置,並且在圖層上執行動畫效果。
首頁Page程式碼:
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:testflutter001/animation/image_detail.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue, splashColor: Colors.transparent),
home: HYHomePage(),
);
}
}
class HYHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Hero動畫"),
),
body: HYHomeContent(),
);
}
}
class HYHomeContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GridView(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2
),
children: List.generate(20, (index) {
String imageURL = "https://picsum.photos/id/$index/400/200";
return GestureDetector(
onTap: () {
Navigator.of(context).push(PageRouteBuilder(
pageBuilder: (ctx, animation, animation2) {
return FadeTransition(
opacity: animation,
child: HYImageDetail(imageURL),
);
}
));
},
child: Hero(
tag: imageURL,
child: Image.network(imageURL)
),
);
}),
);
}
}
複製程式碼
圖片展示Page
import 'package:flutter/material.dart';
class HYImageDetail extends StatelessWidget {
final String imageURL;
HYImageDetail(this.imageURL);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: GestureDetector(
onTap: () {
Navigator.of(context).pop();
},
child: Hero(
tag: imageURL,
child: Image.network(
this.imageURL,
width: double.infinity,
fit: BoxFit.cover,
),
)),
),
);
}
}
複製程式碼
備註:所有內容首發於公眾號,之後除了Flutter也會更新其他技術文章,TypeScript、React、Node、uniapp、mpvue、資料結構與演算法等等,也會更新一些自己的學習心得等,歡迎大家關注