Flutter动画实现粒子漂浮效果

4,181 阅读6分钟
本文所有源码见github.com/MoonRiser/F…

要问2019年最火的移动端框架,肯定非Google的Flutter莫属。

image
本着学习的态度,基本的Dart语法(个人感觉语法风格接近Java+JS)过完之后,开始撸代码练手。


效果图

image

(这里为了方便录制gif,动画设置的较快;如果将动画的Duration设置成20s,看起来就是浮动的效果了) 粒子碰撞的效果参考了张风捷特列 大佬的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类型的数值。我们在控制台打印出结果如下:

image

image

经过观察,两次试验,在2s的动画执行时间内,该回调函数分别被执行了50次,53次,并不是一个固定值。也就是说硬件(模拟器)的屏幕刷新率大概维持在(25~26.5帧/s)。

结论:硬件决定动画刷新率

1.2 动画动起来

搞懂了动画的原理之后,我们接下来就是逐帧的绘制了。关于Flutter的自定义View,跟android原生比较像。

image

继承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画出二次贝塞尔曲线的轨迹,看看小球的运动是否落在轨迹上。

image

2.1 一些细节

animation = CurvedAnimation(parent: controllerG, curve: Curves.bounceOut);

这里的Curve就是前面提到的,控制动画过程的参数,flutter自带了挺多效果,我最喜欢这个bounceOut(弹出效果)

image

 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

image