Flutter动画之粒子精讲

6,409 阅读5分钟

本文所有源码见github/flutter_journey

1.何为动画

1.1:动画说明

见字如面,会动的画面。画面连续渲染,当速度快到一定程度,大脑就会呈现动感

1).何为运动:视觉上看是一个物体在不同的时间轴上表现出不同的物理位置
2).位移 = 初位移 + 速度 * 时间 小学生的知识不多说
3).速度 = 初速度 + 加速度 * 时间 初中生的知识不多说
4).时间、位移、速度、加速度构成了现代科学的运动体系

1.2:关于FPS

那刷新要有多快呢?不知你是否听过FPS,对就是那个游戏里很重要的FPS

FPS : Frames Per Second  画面每秒传输帧数(新率) 单位赫兹(Hz)
60Hz的刷新率刷也就是指屏幕一秒内刷新60次,即60/秒 

其中常见的电影24fps,也就是一秒钟刷新24次。
要达到流畅,需要60fps,这也是游戏中的一个指标,否则就会感觉不流畅  
一秒钟刷新60次,即16.66667ms刷新一次,这也是一个常见的值

1.3:代码中的动画

可以用代码模拟运动,不断刷新的同时改变运动物体的属性从而形成动画
在Android中有ValueAnimator,JavaScript(浏览器)中有``.

1.时间:无限执行----模拟时间流,每次刷新时间间隔,记为:1T
2.位移:物体在屏幕像素位置----模拟世界,每个像素距离记为:1px
3.速度(单位px/T)、加速度(px/T^2)
注意:无论什么语言,只要能够模拟时间与位移,本篇的思想都可以适用,只是语法不同罢了

2.粒子动画

2.1:Flutter中的时间流

通过AnimationController来实现一个不断刷新的舞台,那么表演就交给你了

class RunBall extends StatefulWidget {
  @override
  _RunBallState createState() => _RunBallState();
}

class _RunBallState extends State<RunBall> with SingleTickerProviderStateMixin {
  AnimationController controller;
  var _oldTime = DateTime.now().millisecondsSinceEpoch;//首次运行时时间

  @override
  Widget build(BuildContext context) {
    var child = Scaffold(
    );

    return GestureDetector(//手势组件,做点击响应
      child: child,
      onTap: () {
        controller.forward();//执行动画
      },
    );
  }

  @override
  void initState() {
    controller =//创建AnimationController对象
        AnimationController(duration: Duration(days: 999 * 365), vsync: this);
    controller.addListener(() {//添加监听,执行渲染
      _render();
    });
  }

  @override
  void dispose() {
    controller.dispose(); // 资源释放
  }

  //渲染方法,更新状态
  _render() {
    setState(() {
      var now = DateTime.now().millisecondsSinceEpoch;//每一刷新时间
      print("时间差:${now - _oldTime}ms");//打印时间差
      _oldTime = now;//重新赋值
    });
  }
}

2.2:静态小球的绘制

又到了我们的Canvas了

小球.png

///小球信息描述类
class Ball {
  double aX; //加速度
  double aY; //加速度Y
  double vX; //速度X
  double vY; //速度Y
  double x; //点位X
  double y; //点位Y
  Color color; //颜色
  double r;//小球半径

  Ball({this.x=0, this.y=0, this.color, this.r=10,
        this.aX=0, this.aY=0, this.vX=0, this.vY=0});
}

///画板Painter
class RunBallView extends CustomPainter {
  Ball _ball; //小球
  Rect _area;//运动区域
  Paint mPaint; //主画笔
  Paint bgPaint; //背景画笔

  RunBallView(this._ball,this._area) {
    mPaint = new Paint();
    bgPaint = new Paint()..color = Color.fromARGB(148, 198, 246, 248);
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(_area, bgPaint);
    _drawBall(canvas, _ball);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }

  ///使用[canvas] 绘制某个[ball]
  void _drawBall(Canvas canvas, Ball ball) {
    canvas.drawCircle(
        Offset(ball.x, ball.y), ball.r, mPaint..color = ball.color);
  }
}

var _area= Rect.fromLTRB(0+40.0,0+200.0,280+40.0,200+200.0);
var _ball = Ball(color: Colors.blueAccent, r: 10,x: 40.0+140,y:200.0+100);

---->[使用:_RunBallState#build]----
var child = Scaffold(
  body: CustomPaint(
    painter: RunBallView(_ball,_area),
  ),
);

2.3:远动盒

也就是控制小球在每次刷新时改变其属性,这样视觉上就是运动状态
在边界碰撞后,改变方向即可,通过下面三步,一个运动盒就完成了

速度的合成.png

碰撞分析png

运动盒.gif

//[1].为小球附上初始速度和加速度
var _ball = Ball(color: Colors.blueAccent, r: 10,aY: 0.1, vX: 2, vY: -2,x: 40.0+140,y:200.0+100);

//[2].核心渲染方法,每次调用时更新小球信息
  _render() {
    updateBall();
    setState(() {
      var now = DateTime.now().millisecondsSinceEpoch;
      print("时间差:${now - _oldTime}ms,帧率:${1000/(now - _oldTime)}");
      _oldTime = now;
    });
  }
  
//[3].更新小球的信息
  void updateBall() {
    //运动学公式
    _ball.x += _ball.vX;
    _ball.y += _ball.vY;
    _ball.vX += _ball.aX;
    _ball.vY += _ball.aY;
    //限定下边界
    if (_ball.y > _area.bottom - _ball.r) {
      _ball.y = _area.bottom - _ball.r;
      _ball.vY = -_ball.vY;
      _ball.color=randomRGB();//碰撞后随机色
    }
    //限定上边界
    if (_ball.y < _area.top + _ball.r) {
      _ball.y = _area.top + _ball.r;
      _ball.vY = -_ball.vY;
      _ball.color=randomRGB();//碰撞后随机色
    }

    //限定左边界
    if (_ball.x < _area.left + _ball.r) {
      _ball.x = _area.left + _ball.r;
      _ball.vX = -_ball.vX;
      _ball.color=randomRGB();//碰撞后随机色
    }

    //限定右边界
    if (_ball.x > _area.right - _ball.r) {
      _ball.x = _area.right - _ball.r;
      _ball.vX= -_ball.vX;
      _ball.color=randomRGB();//碰撞后随机色
    }
  }
}

2.4:让小球按照指定的函数图像运动

给定一个较小的dx,随着dx增加,根据函数求出dy,然后更新小球信息
如下面的sin图像,随着每次更新,根据函数关系约束小球坐标值

  double dx=0.0;
  void updateBall(){
    dx+=pi/180;//每次dx增加pi/180
    _ball.x+=dx;
    _ball.y+=f(dx);
  }

  f(x){
    var y= 5*sin(4*x);//函数表达式
    return y;
  }

或者让小球按圆形轨迹运动,下面是通过参数方程让呈圆形轨迹
也就是数学学得好,想怎么跑怎么跑。

  double dx=0.0;
  void updateBall(){
    dx+=pi/180;//每次dx增加pi/180
    _ball.x+=cos(dx);
    _ball.y+=sin(dx);
  }

3.粒子束

3.1:多个粒子运动

一个粒子运动已经够好玩的,那么许多粒子会怎么样?
需要改变的是RunBallView的入参,由一个球换成小球列表,
绘画时批量绘制,更新信息时批量更新

//[1].单体改成列表
class RunBallView extends CustomPainter {
  List<Ball> _balls; //小球列表
  
//[2].绘画时批量绘制
  void paint(Canvas canvas, Size size) {
    _balls.forEach((ball) {
      _drawBall(canvas, ball);
    });
  }

//[3].渲染时批量更改信息
_render() {
  for (var i = 0; i < _balls.length; i++) {
    updateBall(i);
  }
  setState(() {
  });
}

//[4]._RunBallState中初始化时生成随机信息的小球
for (var i = 0; i < 30; i++) {
  _balls.add(Ball(
      color: randomRGB(),
      r: 5 + 4 * random.nextDouble(),
      vX: 3*random.nextDouble()*pow(-1, random.nextInt(20)),
      vY:  3*random.nextDouble()*pow(-1, random.nextInt(20)),
      aY: 0.1,
      x: 200,
      y: 300));
}

也许你觉得画小球没什么,但要知道,小球只是单体,
你可以换成任意你能绘制的东西,甚至是图片或组件


3.2:撞击分裂的效果

也就是在恰当的时机可以添加粒子而达到一定的视觉效果
核心是当到达边界后进行处理,将原来的粒子半径减半,再添加一个等大反向的粒子

//限定下边界
if (ball.y > _area.bottom) {
  var newBall = Ball.fromBall(ball);
  newBall.r = newBall.r / 2;
  newBall.vX = -newBall.vX;
  newBall.vY = -newBall.vY;
  _balls.add(newBall);
  ball.r = ball.r / 2;

  ball.y = _area.bottom;
  ball.vY = -ball.vY;
  ball.color = randomRGB(); //碰撞后随机色
}

当越分越多时,会存在大量绘制,这时可以控制一下条件来移除

void updateBall(int i) {
   var ball = _balls[i];
   if (ball.r < 0.3) {
     //半径小于0.3就移除
     _balls.removeAt(i);
   }
  //略...
}

3.3:特定粒子

现在可以感受到,动画就是元素的信息在不断变化,给人产生的感觉
只要将信息描述好,那么你可以完成任何动画,你就是创造者与主宰者

点阵分析.png

/**
 * 渲染数字
 * @param num    要显示的数字
 * @param canvas 画布
 */
void renderDigit(double radius) {
  var one = [    [0, 0, 0, 1, 1, 0, 0],
    [0, 1, 1, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0],
    [0, 0, 0, 1, 1, 0, 0],
    [1, 1, 1, 1, 1, 1, 1]
  ]; //1
  for (int i = 0; i < one.length; i++) {
    for (int j = 0; j < one[j].length; j++) {
      if (one[i][j] == 1) {
        double rX = j * 2 * (radius + 1) + (radius + 1); //第(i,j)个点圆心横坐标
        double rY = i * 2 * (radius + 1) + (radius + 1); //第(i,j)个点圆心纵坐标
        _balls.add(Ball(
            r: radius,
            x: rX,
            y: rY,
            color: randomRGB(),
            vX: 3 * random.nextDouble() * pow(-1, random.nextInt(20)),
            vY: 3 * random.nextDouble() * pow(-1, random.nextInt(20))));
      }
    }
  }
}

通过一个二维数组记录点位信息,在绘制的时候判断绘制就能呈现既定效果
然后通过信息创建小球,通过渲染展现出来,通过动画将其运动。
其实通过像素点也可以记录这些信息,就可以将图片进行粒子画,
之前在Android粒子篇之Bitmap像素级操作 写得很信息,这里不展开了

总的来说,动画包括三个重要的条件时间流,渲染绘制,信息更新逻辑
这并不只是对于Flutter,任何语言只要满足这三点,粒子动画就可以跑起来
至于有什么用,也许可以提醒我,我不是搬砖的,而是程序设计师一个Creater...


结语

本文到此接近尾声了,如果想快速尝鲜Flutter,《Flutter七日》会是你的必备佳品;如果想细细探究它,那就跟随我的脚步,完成一次Flutter之旅。
另外本人有一个Flutter微信交流群,欢迎小伙伴加入,共同探讨Flutter的问题,本人微信号:zdl1994328,期待与你的交流与切磋。

本文所有源码见github/flutter_journey