本文所有源码见https://github.com/MoonRiser/FlutterLearning
要问2019年最火的移动端框架,肯定非Google的Flutter莫属。
本着学习的态度,基本的Dart语法(个人感觉语法风格接近Java+JS)过完之后,开始撸代码练手。
效果图
粒子碰撞的效果参考了张风捷特列 大佬的Flutter动画之粒子精讲
1. Flutter的动画原理
在任何系统的UI框架中,动画实现的原理都是相同的,即:在一段时间内,快速地多次改变UI外观;由于人眼会产生视觉暂留,所以最终看到的就是一个“连续”的动画,这和电影的原理是一样的。我们将UI的一次改变称为一个动画帧,对应一次屏幕刷新,而决定动画流畅度的一个重要指标就是帧率FPS(Frame Per Second),即每秒的动画帧数。
简而言之,就是逐帧绘制,只要屏幕刷新的足够快,我们就会觉得这是个连续的动画。
设想一个小球从屏幕顶端移动到底端的动画,为了完成这个动画,我们需要哪些数据呢?
- 小球的运动轨迹,即起始点s、终点e和中间任意一点p
- 动画持续时长t
只有这两个参数够吗?明显是不够的,因为小球按照给定的轨迹运动,可能是匀速、先快后慢、先慢后快、甚至是一会儿快一会慢的交替地运动,只要在时间t内完成,都是可能的。所以我们应该再指定一个参数c来控制动画的速度。
1.1 vsync是啥
废话不多说,我们看看Flutter中是动画部分的代码:
AnimationController controller = AnimationController(
vsync: this,
duration: Duration(seconds: 2),
)..addListener(() {
//_renderBezier();
print(controllerG.value);
print('这是第${++count}次回调');
});
简要分析一下,AnimationController,顾名思义,控制器,用来控制动画的播放。传入的参数中,duration我们知道是前面提到过的动画持续时长t,那这个vsync是啥参数呢?打过游戏的同学可能对这个单词有印象,vsync 就是 垂直同步 。那什么是垂直同步呢?
垂直同步又称场同步(Vertical Hold),从CRT显示器的显示原理来看,单个像素组成了水平扫描线,水平扫描线在垂直方向的堆积形成了完整的画面。显示器的刷新率受显卡DAC控制,显卡DAC完成一帧的扫描后就会产生一个垂直同步信号。我们平时所说的打开垂直同步指的是将该信号送入显卡3D图形处理部分,从而让显卡在生成3D图形时受垂直同步信号的制约。
简而言之就是,显卡在完成渲染后,将画面数据送往显存中,而显示器从显存中一行一行从上到下取出画面数据,进行显示。但是屏幕的刷新率和显卡渲染数据的速度很多时候是不匹配的,试想一下,显示器刚扫描显示完屏幕上半部分的画面,正准备从显存取下面的画面数据时,显卡送来了下一帧的图像数据覆盖了原来的显存,这个时候显示器取出的下面部分的图像就和上面的不匹配,造成画面撕裂。
为了避免这种情况,我们引入垂直同步信号,只有在显示器完整的扫描显示完一帧的画面后,显卡收到垂直同步信号才能刷新显存。
可是这个物理信号跟我们flutter的动画有啥关系呢?vsync对应的参数是this,我们继续分析一下this对应的下面的类。
class _RunBallState extends State<RunBall> with TickerProviderStateMixin
with关键字是使用该类的方法而不继承该类,Mixin是类似于Java的接口,区别在于Mixin中的方法不是抽象方法而是已经实现了的方法。
这个TickerProviderStateMixin到底是干啥的呢???经过哥们儿Google的帮助,在网上找到了
关于动画的驱动,在此简单的说一下,Ticker是被SchedulerBinding所驱动。SchedulerBinding则是监听着Window.onBeginFrame回调。
Window.onBeginFrame的作用是什么呢,是告诉应用该提供一个scene了,它是被硬件的VSync信号所驱动的。
于是我们终于发现了,绕了一圈,归根到底还是真正的硬件产生的垂直同步信号在驱动着Flutter的动画的进行。
..addListener(() {
//_renderBezier();
print(controllerG.value);
print('这是第${++count}次回调');
});
注意到之前的代码中存在一个动画控制器的监听器,动画在执行时间内,函数回调controller.value会生成一个从0到1的double类型的数值。我们在控制台打印出结果如下:
经过观察,两次试验,在2s的动画执行时间内,该回调函数分别被执行了50次,53次,并不是一个固定值。也就是说硬件(模拟器)的屏幕刷新率大概维持在(25~26.5帧/s)。
结论:硬件决定动画刷新率
1.2 动画动起来
搞懂了动画的原理之后,我们接下来就是逐帧的绘制了。关于Flutter的自定义View,跟android原生比较像。
继承CustomPainter类,重写paint和shouldRepaint方法,具体实现可以看代码.
class Ball {
double aX;
double aY;
double vX;
double vY;
double x;
double y;
double r;
Color color;}
小球Ball具有圆心坐标、半径、颜色、速度、加速度等属性,通过数学表达式计算速度和加速度的关系,就可以实现匀加速的效果。
//运动学公式,看起来少了个时间t;实际上这些函数在动画过程中逐帧回调,把每帧刷新周期当成单位时间,相当于t=1
_ball.x += _ball.vX;//位移=速度*时间
_ball.y += _ball.vY;
_ball.vX += _ball.aX;//速度=加速度*时间
_ball.vY += _ball.aY;
控制器使得函数不断回调,在回调函数函数里改变小球的相关参数,并且调用setState()函数,使得UI重新绘制;小球的轨迹坐标不断地变化,逐帧绘制的小球看起来就在运动了。你甚至可以在添加效果使得小球在撞到边界时变色或者半径变小(参考文章开头的粒子碰撞效果图)。
2. 小球如何随机浮动
问题来了,我想要一个漂浮的效果呢?最好是随机的轨迹,就像气泡在空中飘乎不定,于是引起了我的思考;匀速然后方向随机?感觉不够优雅,于是去网上搜了一下,发现了思路!
首先随机生成一条贝塞尔曲线作为轨迹,等小球运动到终点,再生成新的贝塞尔曲线轨迹
生成二阶贝塞尔曲线的公式如下:
//二次贝塞尔曲线轨迹坐标,根据参数t返回坐标;起始点p0,控制点p1,终点p2
Offset quadBezierTrack(double t, Offset p0, Offset p1, Offset p2) {
var bx = (1 - t) * (1 - t) * p0.dx + 2 * t * (1 - t) * p1.dx + t * t * p2.dx;
var by = (1 - t) * (1 - t) * p0.dy + 2 * t * (1 - t) * p1.dy + t * t * p2.dy;
return Offset(bx, by);
}
很巧的是,这里需要传入一个0~1之间double类型的参数t,恰好前面我们提过,animationController会在给定的时间内,生成一个0~1的value;这太巧了。
起始点的坐标不用说,接下来就剩解决控制点p1和p2,当然是随机生成这两点,但是如果同时有多个小球呢?比如5个小球同时进行漂浮,每个小球都对应一组三个坐标的信息,给小球Ball添加三个坐标的属性?不,这个时候,我们可以巧妙地利用带种子参数的随机数。
我们知道随机数在生成的时候,如果种子相同的话,每次生成的随机数也是相同的。
每个小球对象在创建的时候自增地赋予一个整形的id,作为随机种子;比如5个小球,我们起始的id为:2,4,6,8,10;
Offset p0 = ball.p0;//起点坐标
Offset p1 = _randPosition(ball.id);
Offset p2 = _randPosition(ball.id + 1);
rand(2),rand(2+1)为第一个小球的p1和p2坐标;当所有小球到达终点时,此时原来的终点p2为新一轮贝塞尔曲线的起点;此时相应的id也应增加,为了防止重复,id应增加小球数量5 *2,即第二轮运动开始时,5个小球的id为:12,14,16,18,20。
这样就保证了每轮贝塞尔曲线运动的时候,对于每个小球而言,p0,p1,p2是确定的;新一轮的运动所需要的随机的三个坐标点,只需要改变id的值就好了。
Path quadBezierPath(Offset p0, Offset p1, Offset p2) {
Path path = new Path();
path.moveTo(p0.dx, p0.dy);
path.quadraticBezierTo(p1.dx, p1.dy, p2.dx, p2.dy);
return path;
}
这个时候,我们还可以利用Flutter自带的api画出二次贝塞尔曲线的轨迹,看看小球的运动是否落在轨迹上。
2.1 一些细节
animation = CurvedAnimation(parent: controllerG, curve: Curves.bounceOut);
这里的Curve就是前面提到的,控制动画过程的参数,flutter自带了挺多效果,我最喜欢这个bounceOut(弹出效果)
animation.addStatusListener((status) {
switch (status) {
case AnimationStatus.dismissed:
// TODO: Handle this case.
break;
case AnimationStatus.forward:
// TODO: Handle this case.
break;
case AnimationStatus.reverse:
// TODO: Handle this case.
break;
case AnimationStatus.completed:
// TODO: Handle this case.
controllerG.reset();
controllerG.forward();
break;
}
});
监听动画过程的状态,当一轮动画结束时,status状态为AnimationStatus.completed;此时,我们将控制器reset重置,再forward重新启动,此时就会开始新一轮的动画效果;如果我们选的是reverse,则动画会反向播放。
GestureDetector(
child: Container(
width: double.infinity,
height: 200,
child: CustomPaint(
painter: FloatBallView(_ballsF, _areaF),
),
),
onTap: () {
controllerG.forward();
},
onDoubleTap: () {
controllerG.stop();
},
),
为了方便控制,我还加了个手势监听器,单击控制动画运行,双击暂停动画。
3 完结
水平有限,文中如有错误还请各位指出,我是梦龙Dragon