Flutter动画

https://coldstone.fun/post/2020/04/26/flutter-animation-from-zero/

动画选择

Flutter动画大的分类来说主要分为两大类:

  • 补间动画:补间动画是一种预先定义物体运动的起点和终点,物体的运动方式,运动时间,时间曲线,然后从起点过渡到终点的动画。
  • 基于物理的动画:基于物理的动画是一种模拟现实世界运动的动画,通过建立运动模型来实现。例如一个篮球🏀从高处落下,需要根据其下落高度,重力加速度,地面反弹力等影响因素来建立运动模型。

Flutter 中多种类型的动画,分别是

  • 隐式动画
  • 显式动画
  • Hero 动画
  • 交织动画
  • 基于物理的动画

一个动画的主要因素有

  • Animation对象是整个动画中非常核心的一个类;
  • AnimationController用于管理Animation;
  • CurvedAnimation过程是非线性曲线;
  • Tween补间动画
  • Listeners和StatusListeners用于监听动画状态改变。

Flutter 动画基于类型化的 Animation 对象,Widgets 通过读取动画对象的当前值和监听状态变化重新运行 build 函数,不断变化 UI 形成动画效果。

AnimationStatus是枚举类型,有4个值;

  • dismissed 动画在开始时停止
  • forward 动画从头到尾绘制r
  • everse 动画反向绘制,从尾到头
  • completed 动画在结束时停止

基础概念

Flutter 动画是建立在以下的概念之上。

Animation

Flutter 中的动画系统基于 Animation 对象, 它是一个抽象类,保存了当前动画的值和状态(开始、暂停、前进、倒退),但不记录屏幕上显示的内容。UI 元素通过读取 Animation 对象的值和监听状态变化运行 build 函数,然后渲染到屏幕上形成动画效果。

一个 Animation 对象在一段时间内会持续生成介于两个值之间的值,比较常见的类型是 Animation<double>,除 double 类型之外还有 Animation<Color> 或者 Animation<Size> 等。

1
2
3
4
abstract class Animation<T> extends Listenable implements ValueListenable<T> {
/// ...
}

AnimationController

带有控制方法的 Animation 对象,用来控制动画的启动,暂停,结束,设定动画运行时间等。

1
2
3
4
5
6
7
8
9
10
class AnimationController extends Animation<double>
with AnimationEagerListenerMixin, AnimationLocalListenersMixin, AnimationLocalStatusListenersMixin {
/// ...
}

AnimationController controller = AnimationController(
vsync: this,
duration: Duration(seconds: 10),
);

Tween

用来生成不同类型和范围的动画取值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Tween<T extends dynamic> extends Animatable<T> {
Tween({ this.begin, this.end });
/// ...
}

// double 类型
Tween<double> tween = Tween<double>(begin: -200, end: 200);

// color 类型
ColorTween colorTween = ColorTween(begin: Colors.blue, end: Colors.yellow);

// border radius 类型
BorderRadiusTween radiusTween = BorderRadiusTween(
begin: BorderRadius.circular(0.0),
end: BorderRadius.circular(150.0),
);

Curve

Flutter 动画的默认动画过程是匀速的,使用 CurvedAnimation 可以将时间曲线定义为非线性曲线。

1
2
3
4
5
6
class CurvedAnimation extends Animation<double> with AnimationWithParentMixin<double> {
/// ...
}

Animation animation = CurvedAnimation(parent: controller, curve: Curves.easeIn);

Ticker

Ticker 用来添加每次屏幕刷新的回调函数 TickerCallback,每次屏幕刷新都会调用。类似于 Web 里面的 requestAnimationFrame 方法。

1
2
3
4
5
class Ticker {
/// ...
}

Ticker ticker = Ticker(callback);

隐式动画

隐式动画使用 Flutter 框架内置的动画部件创建,通过设置动画的起始值和最终值来触发。当使用 setState 方法改变部件的动画属性值时,框架会自动计算出一个从旧值过渡到新值的动画。

比如 AnimatedOpacity 部件,改变它的 opacity 值就可以触发动画。

除了 AnimatedOpacity 外,还有其他的内置隐式动画部件如:AnimatedContainer, AnimatedPadding, AnimatedPositioned, AnimatedSwitcherAnimatedAlign 等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import 'package:flutter/material.dart';

class OpacityChangePage extends StatefulWidget {
@override
_OpacityChangePageState createState() => _OpacityChangePageState();
}

class _OpacityChangePageState extends State<OpacityChangePage> {
double _opacity = 1.0;

// 改变目标值
void _toggle() {
_opacity = _opacity > 0 ? 0.0 : 1.0;
setState(() {});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('隐式动画')),
body: Center(
child: AnimatedOpacity(
// 传入目标值
opacity: _opacity,
duration: Duration(seconds: 1),
child: Container(
width: 200,
height: 200,
color: Colors.blue,
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _toggle,
child: Icon(Icons.play_arrow),
),
);
}
}

显式动画

显式动画指的是需要手动设置动画的时间,运动曲线,取值范围的动画。将值传递给动画部件如: RotationTransition,最后使用一个AnimationController 控制动画的开始和结束。

除了 RotationTransition 外,还有其他的显示动画部件如:FadeTransition, ScaleTransition, SizeTransition, SlideTransition 等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import 'dart:math';
import 'package:flutter/material.dart';

class RotationAinmationPage extends StatefulWidget {
@override
_RotationAinmationPageState createState() => _RotationAinmationPageState();
}

class _RotationAinmationPageState extends State<RotationAinmationPage>
with SingleTickerProviderStateMixin {
AnimationController _controller;
Animation<double> _turns;
bool _playing = false;

// 控制动画运行状态
void _toggle() {
if (_playing) {
_playing = false;
_controller.stop();
} else {
_controller.forward()..whenComplete(() => _controller.reverse());
_playing = true;
}
setState(() {});
}

@override
void initState() {
super.initState();
// 初始化动画控制器,设置动画时间
_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 10),
);

// 设置动画取值范围和时间曲线
_turns = Tween(begin: 0.0, end: pi * 2).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeIn),
);
}

@override
void dispose() {
super.dispose();
_controller.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('显示动画')),
body: Center(
child: RotationTransition(
// 传入动画值
turns: _turns,
child: Container(
width: 200,
height: 200,
child: Image.asset(
'assets/images/fan.png',
fit: BoxFit.cover,
),
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _toggle,
child: Icon(_playing ? Icons.pause : Icons.play_arrow),
),
);
}
}

Hero 动画

Hero 动画指的是在页面切换时一个元素从旧页面运动到新页面的动画。Hero 动画需要使用两个 Hero 控件实现:一个用来在旧页面中,另一个在新页面。两个 Hero 控件需要使用相同的 tag 属性,并且不能与其他tag重复。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// 页面 1
import 'package:flutter/material.dart';

import 'hero_animation_page2.dart';

String cake1 = 'assets/images/cake01.jpg';
String cake2 = 'assets/images/cake02.jpg';

class HeroAnimationPage1 extends StatelessWidget {
GestureDetector buildRowItem(context, String image) {
return GestureDetector(
onTap: () {
// 跳转到页面 2
Navigator.of(context).push(
MaterialPageRoute(builder: (ctx) {
return HeroAnimationPage2(image: image);
}),
);
},
child: Container(
width: 100,
height: 100,
child: Hero(
// 设置 Hero 的 tag 属性
tag: image,
child: ClipOval(child: Image.asset(image)),
),
),
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('页面 1')),
body: Column(
children: <Widget>[
SizedBox(height: 40.0),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
buildRowItem(context, cake1),
buildRowItem(context, cake2),
],
),
],
),
);
}
}

// 页面 2
import 'package:flutter/material.dart';

class HeroAnimationPage2 extends StatelessWidget {
final String image;

const HeroAnimationPage2({@required this.image});

@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
expandedHeight: 400.0,
title: Text('页面 2'),
backgroundColor: Colors.grey[200],
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
background: Hero(
// 使用从页面 1 传入的 tag 值
tag: image,
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(image),
fit: BoxFit.cover,
),
),
),
),
),
),
SliverList(
delegate: SliverChildListDelegate(
<Widget>[
Container(height: 600.0, color: Colors.grey[200]),
],
),
),
],
),
);
}
}

交织动画

交织动画是由一系列的小动画组成的动画。每个小动画可以是连续或间断的,也可以相互重叠。其关键点在于使用 Interval 部件给每个小动画设置一个时间间隔,以及为每个动画的设置一个取值范围 Tween,最后使用一个 AnimationController 控制总体的动画状态。

Interval 继承至 Curve 类,通过设置属性 beginend 来确定这个小动画的运行范围。

1
2
3
4
5
6
7
8
9
10
11
12
class Interval extends Curve {
/// ...

/// 动画起始点
final double begin;
/// 动画结束点
final double end;
/// 动画缓动曲线
final Curve curve;

/// ...
}

这是一个由 5 个小动画组成的交织动画,宽度,高度,颜色,圆角,边框,每个动画都有自己的动画区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import 'package:flutter/material.dart';

class StaggeredAnimationPage extends StatefulWidget {
@override
_StaggeredAnimationPageState createState() => _StaggeredAnimationPageState();
}

class _StaggeredAnimationPageState extends State<StaggeredAnimationPage>
with SingleTickerProviderStateMixin {
AnimationController _controller;
Animation<double> _width;
Animation<double> _height;
Animation<Color> _color;
Animation<double> _border;
Animation<BorderRadius> _borderRadius;

void _play() {
if (_controller.isCompleted) {
_controller.reverse();
} else {
_controller.forward();
}
}

@override
void initState() {
super.initState();

_controller = AnimationController(
vsync: this,
duration: Duration(seconds: 5),
);

_width = Tween<double>(
begin: 100,
end: 300,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(
0.0,
0.2,
curve: Curves.ease,
),
),
);

_height = Tween<double>(
begin: 100,
end: 300,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(
0.2,
0.4,
curve: Curves.ease,
),
),
);

_color = ColorTween(
begin: Colors.blue,
end: Colors.yellow,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(
0.4,
0.6,
curve: Curves.ease,
),
),
);

_borderRadius = BorderRadiusTween(
begin: BorderRadius.circular(0.0),
end: BorderRadius.circular(150.0),
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(
0.6,
0.8,
curve: Curves.ease,
),
),
);

_border = Tween<double>(
begin: 0,
end: 25,
).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(0.8, 1.0),
),
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('交织动画')),
body: Center(
child: AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget child) {
return Container(
width: _width.value,
height: _height.value,
decoration: BoxDecoration(
color: _color.value,
borderRadius: _borderRadius.value,
border: Border.all(
width: _border.value,
color: Colors.orange,
),
),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: _play,
child: Icon(Icons.refresh),
),
);
}
}

物理动画

物理动画是一种模拟现实世界物体运动的动画。需要建立物体的运动模型,以一个物体下落为例,这个运动受到物体的下落高度,重力加速度,地面的反作用力等因素的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

class ThrowAnimationPage extends StatefulWidget {
@override
_ThrowAnimationPageState createState() => _ThrowAnimationPageState();
}

class _ThrowAnimationPageState extends State<ThrowAnimationPage> {
// 球心高度
double y = 70.0;
// Y 轴速度
double vy = -10.0;
// 重力
double gravity = 0.1;
// 地面反弹力
double bounce = -0.5;
// 球的半径
double radius = 50.0;
// 地面高度
final double height = 700;

// 下落方法
void _fall(_) {
y += vy;
vy += gravity;

// 如果球体接触到地面,根据地面反弹力改变球体的 Y 轴速度
if (y + radius > height) {
y = height - radius;
vy *= bounce;
} else if (y - radius < 0) {
y = 0 + radius;
vy *= bounce;
}

setState(() {});
}

@override
void initState() {
super.initState();
// 使用一个 Ticker 在每次更新界面时运行球体下落方法
Ticker(_fall)..start();
}

@override
Widget build(BuildContext context) {
double screenWidth = MediaQuery.of(context).size.width;

return Scaffold(
appBar: AppBar(title: Text('物理动画')),
body: Column(
children: <Widget>[
Container(
height: height,
child: Stack(
children: <Widget>[
Positioned(
top: y - radius,
left: screenWidth / 2 - radius,
child: Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
),
],
),
),
Expanded(child: Container(color: Colors.blue)),
],
),
);
}
}

其他资料

https://book.flutterchina.club/chapter9/intro.html