Flutter 复杂 Loading 动画的抽丝剥茧

1,635 阅读6分钟

一、前言

之前在我的一篇文章 iOS复杂动画之抽丝剥茧 中有分析过一个复杂的 Loading 动画的实现过程,现在有意看了下 Flutter 的动画,所以就有了一个用 Flutter 来实现这个动画当练习的想法,最终使用 Flutter 实现效果如下

下面我们就来重新分析下这个动画的实现过程

二、动画的步骤分析

上面图中的动画第一眼看起来的确是有点复杂,但是我们来一步步分析,就会发现其实并不是那么难。仔细看一下就会发现,大致步骤如下:

1、先出来一个圆

2、圆形在水平和竖直方向上被挤压,呈椭圆形状的一个过程,最后恢复成圆形

3、圆形的左下角、右下角和顶部分别按顺序凸出一小部分(内部三角形拉伸)

4、圆和凸出部分形成的图形旋转一圈后变成三角形(三角形不变,圆缩小)

5、三角形的左边先后出来两个画矩形边框的动画,将三角形围在矩形中

6、矩形由底部向上被波浪状填满

7、被填满的矩形放大至全屏,弹出 Welcome

大致步骤如上,下面我们就来一步步实现每个步骤。

三、抽丝剥茧

1.分析

因为在 Flutter 中,万物皆 widget,所以首先根据我们的分析,我们大概需要以下几个 widget

  • 圆形
  • 三角,
  • 两个矩形边框
  • 水波
  • Text 文本

首先我们需要创建一个动画的控制器,然后依次是动画三要素

  • AnimationController
  • CurvedAnimation
  • Tween

2.实现圆形变化

圆的变化过程大致如下(w -> width, h -> height):

  • (h = 0, w = 0) 此时无圆形
  • (h = 120, w = 120) 圆形由小变大
  • (h = 120, w = 130)->(h = 120, w = 120)->(h = 130, w = 120)->(h = 120, w = 120)圆形变成椭圆的过程
  • (h = 0, w = 0) 圆形消失 上述过程就是圆在整个动画周期内的变化过程,所以我们用 TweenSequence 来实现每个时间段内的时间比重
  // 最开始圆的宽度变化
  static TweenSequence circleWidthTweenSequence = TweenSequence([
    TweenSequenceItem(tween: Tween(begin: 0.0, end: 120.0), weight: 5),
    TweenSequenceItem(tween: Tween(begin: 120.0, end: 130.0), weight: 10),
    TweenSequenceItem(tween: Tween(begin: 130.0, end: 120.0), weight: 10),
    TweenSequenceItem(tween: ConstantTween<double>(120.0), weight: 20),
    TweenSequenceItem(tween: Tween(begin: 120.0, end: 0.0), weight: 10),
    TweenSequenceItem(tween: ConstantTween<double>(0.0), weight: 45),
  ]);
//最开始圆的高度变化
  static TweenSequence circleHeightTweenSequence = TweenSequence([
    TweenSequenceItem(tween: Tween(begin: 0.0, end: 120.0), weight: 5),
    TweenSequenceItem(tween: ConstantTween<double>(120.0), weight: 20),
    TweenSequenceItem(tween: Tween(begin: 120.0, end: 130.0), weight: 10),
    TweenSequenceItem(tween: Tween(begin: 130.0, end: 120.0), weight: 10),
    TweenSequenceItem(tween: Tween(begin: 120.0, end: 0.0), weight: 10),
    TweenSequenceItem(tween: ConstantTween<double>(0.0), weight: 45),
  ]);

这样基于上面,就可以做出来圆的整个生命周期的动画

...

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (BuildContext context, Widget child) {
        return Center(
          child: ClipRRect(
            borderRadius: BorderRadius.circular(60),
            child: Container(
              color: Colors.purple,
              height: _circleHeightTween.value,
              width: _circleWidthTween.value,
            ),
          ),
        );
      },
    );
  }

效果如下 circle.gif

3.实现三角形变化

三角形的变化过程其实很简单,主要是以下几步

  • 三角形从 0 到 大
  • 三角形分别左边、右边、上边三个角拉长
  • 旋转

知道了三角形的变化过程,首先我们需要绘制出来一个三角形,由于我们并没有三角形这种 widget,所以就需要我们手动去实现。其实在 Flutter 中实现各种复杂的图形也很简单,Flutter 为我们提供了一个 CustomPainter 抽象类,我们只要继承然后实现 paintshouldRepaint 这两个抽象方法即可,自定义三角形实现如下

class TrianglePainter extends CustomPainter {
  Color color;
  Paint _paint = Paint()
    ..strokeWidth = 5.0
    ..color = Colors.purple
    ..isAntiAlias = true
    ..strokeJoin = StrokeJoin.round;
  Path _path = Path();
  double left, right, top;
  TrianglePainter({this.left, this.right, this.top});

  @override
  void paint(Canvas canvas, Size size) {
    final _width = size.width;
    final _height = size.height;
    _path.moveTo(left * _width, 0.85 * _height);
    _path.lineTo(right * _width, 0.85 * _height);
    _path.lineTo(0.5 * _width, top * _height);
    _path.close();
    canvas.drawPath(_path, _paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

然后三角形变化的 Tween 如下

// 三角形size变化
  static TweenSequence triangleSizeTweenSequence = TweenSequence([
    TweenSequenceItem(tween: Tween(begin: 0.0, end: 120.0), weight: 15),
    TweenSequenceItem(tween: ConstantTween<double>(120.0), weight: 85),
  ]);

// 三角形的左、右、上变化过程
  static TweenSequence triangleLeftTweenSequence = TweenSequence([
    TweenSequenceItem(tween: ConstantTween<double>(0.2), weight: 15),
    TweenSequenceItem(tween: Tween(begin: 0.2, end: 0.02), weight: 10),
    TweenSequenceItem(tween: ConstantTween<double>(0.02), weight: 75),
  ]);
  static TweenSequence triangleRightTweenSequence = TweenSequence([
    TweenSequenceItem(tween: ConstantTween<double>(0.8), weight: 25),
    TweenSequenceItem(tween: Tween(begin: 0.8, end: 0.98), weight: 10),
    TweenSequenceItem(tween: ConstantTween<double>(0.98), weight: 65),
  ]);
  static TweenSequence triangleTopTweenSequence = TweenSequence([
    TweenSequenceItem(tween: ConstantTween<double>(0.05), weight: 35),
    TweenSequenceItem(tween: Tween(begin: 0.05, end: -0.1), weight: 10),
    TweenSequenceItem(tween: ConstantTween<double>(-0.1), weight: 55),
  ]);

// 整体旋转的变化过程
  static TweenSequence rotationTweenSequence = TweenSequence([
    TweenSequenceItem(tween: ConstantTween<double>(0.0), weight: 45),
    TweenSequenceItem(tween: Tween(begin: 0.0, end: 2.0), weight: 10),
    TweenSequenceItem(tween: ConstantTween<double>(2.0), weight: 45),
  ]);

最后拿到 Tween 值去渲染动画

...

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (BuildContext context, Widget child) {
        return Center(
          child: Transform(
            alignment: Alignment.center,
            transform: Matrix4.rotationZ(Math.pi * _rotationTween.value),
            child: Container(
              height: _triangleSizeTween.value,
              width: _triangleSizeTween.value,
              child: CustomPaint(
                painter: TrianglePainter(
                  left: _triangleLeftTween.value,
                  right: _triangleRightTween.value,
                  top: _triangleTopTween.value,
                ),
              ),
            ),
          ),
        );
      },
    );
  }

最后三角形动画变化效果如下

triangle.gif

4.实现矩形框变化

同样,矩形框的变化我们也得使用 CustomPainter

class SquarePainter extends CustomPainter {
  double progress;
  Color color;
  final Paint _paint = Paint()
    ..strokeCap = StrokeCap.round
    ..style = PaintingStyle.stroke
    ..strokeWidth = 5;
  SquarePainter({this.progress, this.color = Colors.purple});

  @override
  void paint(Canvas canvas, Size size) {
    _paint.color = color;
    if (progress > 0) {
      var path = createPath(4, size.width);
      PathMetric pathMetric = path.computeMetrics().first;
      Path extractPath =
          pathMetric.extractPath(0.0, pathMetric.length * progress);
      canvas.drawPath(extractPath, _paint);
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;

  Path createPath(int sides, double radius) {
    Path path = Path();
    // 根据三角形的系数画矩形
    double wFartor = 0.02; //左下
    double hFactor = 0.85; //右下
    double tFactor = 0.10; //顶部三角形
    path.moveTo(wFartor * radius, hFactor * radius);
    for (int i = 1; i <= sides; i++) {
      double x, y;
      if (i == 1) {
        x = wFartor * radius;
        y = -tFactor * radius;
      } else if (i == 2) {
        x = radius;
        y = -tFactor * radius;
      } else if (i == 3) {
        x = radius;
        y = radius * hFactor;
      } else {
        x = wFartor * radius;
        y = radius * hFactor;
      }
      path.lineTo(x, y);
    }
    path.close();
    return path;
  }
}

需要注意的是,我们需要用 PathMetric 来拿到路径, 类似于 Android 中的 PathMeasure.getSegment(), 两个线性矩形的变化 Tween 如下

 // 线性矩形变化
  static TweenSequence rectTweenSequence1 = TweenSequence([
    TweenSequenceItem(tween: ConstantTween<double>(0.0), weight: 55),
    TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 10),
    TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.0), weight: 35),
  ]);
  static TweenSequence rectTweenSequence2 = TweenSequence([
    TweenSequenceItem(tween: ConstantTween<double>(0.0), weight: 65),
    TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 10),
    TweenSequenceItem(tween: ConstantTween<double>(1.0), weight: 25),
  ]);

由于是两个矩形的变化,所以我们使用 Stack 包裹

...

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (BuildContext context, Widget child) {
        return Center(
          child: Transform(
            alignment: Alignment.center,
            transform: Matrix4.rotationZ(Math.pi * _rotationTween.value),
            child: Stack(
              alignment: Alignment.center,
              children: [
                Container(
                  height: _triangleSizeTween.value,
                  width: _triangleSizeTween.value,
                  child: CustomPaint(
                    painter: SquarePainter(progress: _rect1Tween.value),
                  ),
                ),
                Container(
                  height: _triangleSizeTween.value,
                  width: _triangleSizeTween.value,
                  child: CustomPaint(
                    painter: SquarePainter(
                      progress: _rect2Tween.value,
                      color: Color(0xff40e0b0),
                    ),
                  ),
                )
              ],
            ),
          ),
        );
      },
    );
}

最后实现效果如下 square.gif

5.实现水波变化以及放大效果

实现水波变化稍微复杂一点,因为我们整个过程所有的动画都是用一个 AnimationController 来控制,所以我们还需要一个 Animation 来控制水波震荡的效果,但是我们的 _HWAnimatePageState 是继承于 SingleTickerProviderStateMixin 的,里面只能有一个`AnimationController。基于这种情况,将水波动画抽成了一个单独的 widget,可以在 自定义wave_progress 看到源码,画水波代码如下

  // 画水波纹动画
   Paint wavePaint = new Paint()..color = waveColor;
   // 水波振幅
   double amp = 2.0;
   double p = progress / 100.0;
   double baseHeight = (1 - p) * size.height;

   Path path = Path();
   path.moveTo(0.0, baseHeight);
   for (double i = 0.0; i < size.width; i++) {
     path.lineTo(
         i,
         baseHeight +
             Math.sin((i / size.width * 2 * Math.pi) +
                     (animation.value * 2 * Math.pi)) * amp - 15);
   }

   path.lineTo(size.width, size.height - 15);
   path.lineTo(0.0, size.height - 15);
   path.close();
   canvas.drawPath(path, wavePaint);

水波升高变大,然后显示文本的 Tween 过程如下

// 水波升高变化动画
 static TweenSequence waveTweenSequence = TweenSequence([
   TweenSequenceItem(tween: ConstantTween<double>(0.0), weight: 75),
   TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 10),
   TweenSequenceItem(tween: ConstantTween<double>(1.0), weight: 15),
 ]);
 // 水波宽高变化
 static TweenSequence waveWidthTweenSequence = TweenSequence([
   TweenSequenceItem(tween: ConstantTween<double>(120.0), weight: 80),
   TweenSequenceItem(tween: Tween(begin: 120.0, end: screenWidth), weight: 10),
   TweenSequenceItem(tween: ConstantTween<double>(screenWidth), weight: 10),
 ]);
 static TweenSequence waveHeightTweenSequence = TweenSequence([
   TweenSequenceItem(tween: ConstantTween<double>(120.0), weight: 80),
   TweenSequenceItem(
       tween: Tween(begin: 120.0, end: screenHeight), weight: 10),
   TweenSequenceItem(tween: ConstantTween<double>(screenHeight), weight: 10),
 ]);
 // 最后显示的文本变化过程
 static TweenSequence textSizeTweenSequence = TweenSequence([
   TweenSequenceItem(tween: ConstantTween<double>(0), weight: 85),
   TweenSequenceItem(tween: Tween(begin: 0.0, end: 30.0), weight: 10),
   TweenSequenceItem(tween: ConstantTween<double>(50), weight: 5),
 ]);

根据 Tween 实现效果如下

wave.gif

6.实现组合动画

将上面步骤的动画效果都放在一个 Stack

 @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (BuildContext context, Widget child) {
        return Center(
          child: Transform(
            alignment: Alignment.center,
            transform: Matrix4.rotationZ(Math.pi * _rotationTween.value),
            child: Stack(
              alignment: Alignment.center,
              children: [
                ClipRRect(
                  borderRadius: BorderRadius.circular(60),
                  child: Container(
                    color: Colors.purple,
                    height: _circleHeightTween.value,
                    width: _circleWidthTween.value,
                  ),
                ),
                Container(
                  height: _triangleSizeTween.value,
                  width: _triangleSizeTween.value,
                  child: CustomPaint(
                    painter: TrianglePainter(
                      left: _triangleLeftTween.value,
                      right: _triangleRightTween.value,
                      top: _triangleTopTween.value,
                    ),
                  ),
                ),
                Container(
                  height: _triangleSizeTween.value,
                  width: _triangleSizeTween.value,
                  child: CustomPaint(
                    painter: SquarePainter(progress: _rect1Tween.value),
                  ),
                ),
                Container(
                  height: _triangleSizeTween.value,
                  width: _triangleSizeTween.value,
                  child: CustomPaint(
                    painter: SquarePainter(
                      progress: _rect2Tween.value,
                      color: Color(0xff40e0b0),
                    ),
                  ),
                ),
                Container(
                  height: _waveHeightTween.value,
                  width: _waveWidthTween.value,
                  child: WaveProgress(
                    size: 120,
                    borderWidth: 0.0,
                    backgroundColor: Colors.transparent,
                    borderColor: Colors.transparent,
                    waveColor: Color(0xff40e0b0),
                    progress: 100 * _waveProgressTween.value,
                    offsetY: _waveOffsetYTween.value,
                  ),
                ),
                Text(
                  'Welcome',
                  style: TextStyle(
                    fontSize: _textSizeTween.value,
                    color: Colors.white,
                  ),
                )
              ],
            ),
          ),
        );
      },
    );
  }

这样每个 widget 就会根据自己所依赖的 Tween 值去做动画了,最后实现了 Loading 动画的效果。

四、最后

其实相对于原来 iOS 原生开发来说,Flutter 实现一些效果更加方便,比如 Layout,比如 Hero 动画,所以我是比较看好 Flutter 的。后面也会更多的去分享一些 Flutter 的知识。就比如说这个动画其实分析下来每一步也不是太难,但是要有足够的耐心去分析,迎难而上。 最后所有的源码你都可以从 这里 看到,欢迎 star!