Flutter 复杂 Loading 动画的抽丝剥茧

一、前言

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

welcome.gif

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

二、动画的步骤分析

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

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!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345