前言

原本是想接着上次的简单爆炸效果,找个代码写一篇Widget点击爆炸+粒子曲线掉落的教程的,但是因为我数学比较渣,完全看不懂作者的一堆常量+组合是配哪个公式(作者本身也没有任何注释),所以还是等到周末的时候再看看吧。

这次就先来实现一个冒泡背景吧,效果图👇:
middle_img_v2_4b5e998b-f376-40b4-89c7-e29a1c25de4g

  • 受Gif限制,帧数看起来有点低,实际帧数可以稳在59~60

QQ20210709021230.gif

原文教程链接:传送门。没错,跟简单爆炸是同一个作者。
本文额外修复了作者的一个问题,即后台切换优化。
完整代码:https://gitee.com/wenjie2018/flutter_bubble_demo
Flutter SDK: 2.2.2
dependencies: simple_animations: ^3.0.1


从一个点开始

我认为任何一个复杂动画都是通过不同单元动画组合而成的,所以接下来我还是会从一个粒子的动画逐步过渡到整个完整动画。

在第一个粒子诞生之前,我们先拟定好一些规则,如下图所示👇:
image.png

  • 偏移量是基于屏幕左上角开始的计算的,所以x是向右递增,y是向下递增。这点你可以根据自己的喜好调整。
  • 虚拟边界存在的意义是:粒子开始时可以从屏幕外诞生并进入屏幕内,结束时从屏幕外消失,这样就不会出现凭空诞生/消失的诡异粒子。

单点固定动画

好了,接下来我们先实现一个粒子从虚拟边界底部->顶部的简单动画,思路是这样的:
image.png

  • 构建循环动画区间——LoopAnimation实现,不断地执行推粒子的动作(如果粒子List不为空)
  • 为了粒子能重叠,粒子对象肯定是用Stack
  • x轴暂时在中间就行,补间(Tween)值为:0.5 -> 0.5
  • y轴要从虚拟边界的底部(1.2)到顶部(-0.2),故补间(Tween)值为:1.2 -> -0.2
  • 根据补间(Tween)获取x,y偏移,用CustomPainter来绘制粒子
  • 当粒子到达虚拟边界顶部时,重置粒子的动画进度为0(这样就会重新从下面冒出来了)
  • 推动粒子的循环构建好了,那么剩下的就是初始化的时候给List添加一个粒子元素就OK了,因为粒子数不是动态变化的,所以我们可以选择在构造函数里面传入

接下来上实现代码👇:


import 'package:flutter/material.dart';
import 'package:simple_animations/simple_animations.dart';
import 'package:supercharged/supercharged.dart';
import 'package:supercharged_dart/supercharged_dart.dart';

void main() {
  runApp(new MaterialApp(home: Page()));
}

class Page extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => PageState();
}

class PageState extends State<Page> {

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Container(
          color: Colors.black87,
          child: Stack(
              children: <Widget>[
                // 冒泡动效
                Positioned.fill(child: ParticlesWidget(1)),
              ]
          ),
        )
    );
  }
}

/// ///////////////////////////////////////////////////////////////////////////
///
/// 冒泡动效Widget
///
/// ///////////////////////////////////////////////////////////////////////////
class ParticlesWidget extends StatefulWidget {
  /// 粒子数量
  final int numberOfParticles;

  ParticlesWidget(this.numberOfParticles);

  @override
  _ParticlesWidgetState createState() => _ParticlesWidgetState();
}

class _ParticlesWidgetState extends State<ParticlesWidget> {

  final List<ParticleModel> particles = [];

  @override
  void initState() {
    widget.numberOfParticles.times(() => particles.add(ParticleModel()));
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return LoopAnimation(
      tween: ConstantTween(1),
      builder: (context, child, dynamic _) {
        // 如果粒子动画完成,则重来
        _simulateParticles();
        return CustomPaint(
          painter: ParticlePainter(particles),
        );
      },
    );
  }

  /// 如果粒子动画结束,则调用 [ParticleModel.restart]
  _simulateParticles() {
    particles
        .forEach((particle) => particle.checkIfParticleNeedsToBeRestarted());
  }
}

/// ///////////////////////////////////////////////////////////////////////////
///
/// 绘画逻辑
/// 不懂的参考:<https://book.flutterchina.club/chapter10/custom_paint.html#custompainter>
///
/// ///////////////////////////////////////////////////////////////////////////
class ParticlePainter extends CustomPainter {
  List<ParticleModel> particles;

  ParticlePainter(this.particles);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..color = Colors.white.withAlpha(50);

    particles.forEach((particle) {
      final progress = particle.progress();
      final MultiTweenValues<ParticleOffsetProps> animation =
      particle.tween.transform(progress);
      final position = Offset(
        animation.get<double>(ParticleOffsetProps.x) * size.width,
        animation.get<double>(ParticleOffsetProps.y) * size.height,
      );
      canvas.drawCircle(position, size.width * 0.2 * particle.size, paint);
    });
  }

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

/// ///////////////////////////////////////////////////////////////////////////
///
/// 粒子对象,包含各种属性
///
/// ///////////////////////////////////////////////////////////////////////////
enum ParticleOffsetProps { x, y }

class ParticleModel {
  /// 粒子坐标补间
  late MultiTween<ParticleOffsetProps> tween;
  /// 粒子大小
  late double size;
  /// 动画过渡时间
  late Duration duration;
  /// 动画开始时间
  late Duration startTime;

  ParticleModel() {
    restart();
  }

  /// 重置粒子属性
  restart() {

    // 对于Y轴:0为屏幕顶部,1位屏幕底部,-0.2为顶部外20%区域,1.2为底部20%
    // 起始坐标(x,y)
    final startPosition = Offset(0.5, 1.2);
    // 结束坐标(X,y)
    final endPosition = Offset(0.5, -0.2);

    tween = MultiTween<ParticleOffsetProps>()
      ..add(ParticleOffsetProps.x, startPosition.dx.tweenTo(endPosition.dx))
      ..add(ParticleOffsetProps.y, startPosition.dy.tweenTo(endPosition.dy));

    // 动画过渡时间
    duration = 3.seconds;
    // 开始时间
    startTime = DateTime.now().duration();
    // 粒子大小
    size = 0.6;
  }

  checkIfParticleNeedsToBeRestarted() {
    if (progress() == 1.0) {
      restart();
    }
  }

  /// 获取动画进度
  /// 0 表示开始, 1 表示结束,相当于动画进度的百分比
  double progress() {
    return ((DateTime.now().duration() - startTime) / duration)
        .clamp(0.0, 1.0)
        .toDouble();
  }
}

效果如下👇:
middle_img_v2_08c2329e92e147218d630c4f2f50d50g.gif


粒子随机大小

这个很简单,给restart方法中的粒子大小加上随机就可以了,为了防止粒子大小为0,可以设置一个固定值+随机值

  restart() {
    ...(略)
    // 粒子大小
    size = 0.2 + Random().nextDouble() * 0.4;
  }

效果如下,出的粒子大小变了👇:
middle_img_v2_772d25fa927446d49d6b4a7065b5015g.gif


随机起/终点

为了让粒子不只是直直的移动,我们需要将起点的x坐标和终点的x坐标随机化,y坐标还是原来的1.2->-0.2不用变:

image.png

改动的函数还是restart,对应代码改动如下👇:

  restart() {

    // 对于Y轴:0为屏幕顶部,1位屏幕底部,-0.2为顶部外20%区域,1.2为底部20%
    // 起始坐标(x,y)
    final startPosition = Offset(-0.2 + 1.4 * Random().nextDouble(), 1.2);
    // 结束坐标(X,y)
    final endPosition = Offset(-0.2 + 1.4 * Random().nextDouble(), -0.2);
    ...(略)
  }

改造后效果如下👇:
middle_img_v2_6454ef1645194425a23277ab3a19220g.gif


增加粒子

接下来的事情就比较简单了,首先我们把原本的1个粒子改成50个,对应代码如下:

...(略)
Positioned.fill(child: ParticlesWidget(50))

现在效果如下👇:
middle_img_v2_35dacbf3e539443ca88e9521c2f1c89g.gif

上面的效果看起来有点魔幻,那是因为我们每个粒子的动画过渡时间都是相等的,为了让粒子分布更均匀,我们把动画的过渡时间也加上随机值,代码改动如下👇:

  restart() {
    // 动画过渡时间
    duration = 3.seconds + Random().nextInt(30000).milliseconds;
    ...(其余代码略)
  }

改造后,待app启动10秒+后效果如下👇:
middle_img_v2_d87d2bb1-bbda-4ec3-a8df-2fc55f97786g

  • 已经有内味了,但是别急,还有可以优化的地方

启动优化

细心的小伙伴可能已经注意到了,我上面特地加粗了待app启动10秒+后,这是因为这个动画刚打开的时候是这样的👇:
middle_img_v2_43ff9ed709474a6e856312a0a30164fg.gif

又或者你把应用切到后台,等大概30秒左右,再切回来,会发现动画又被重置了,就像下面这样👇:
middle_img_v2_2442c6fba07c44719ea373ad5ba13bag.gif

发生这种现象的原因其实就是我们让所有粒子的起点时间相同了,那么怎么做才能让应用启动的时候就让部分粒子的动画“偷跑”呢?其实很简单,我们现在的动画是通过progress()函数获取动画的进度(这个方法返回值在0~1之间,也就是相当于当前动画进度的百分比),然后根据进度值获取补间值(Tween)的。也就是说,我们只需要改变进度,就能改变动画绘制的起始位置了,比如当某个粒子的progress()返回值为0.5时,它必定在屏幕y轴的中间位置。

image.png

影响progress()的参数有startTimeduration,显然duration之前为了让粒子不排成一条线已经随机过了,那么接下来就是要更改startTime了。

我们的目的是要让应用启动的时候,粒子就均匀分布,也就是说得要让粒子“预播”。实现这个其实也很简单,我们只需要把startTime往前推就行了,startTime减小,最终progress()的初始返回值就必定>0,有可能会出现上面=0.5的情况,那么我们打开app的时候就能看到一个粒子在y轴中间了。

接下来就是代码改动,我们要新增一个函数:

  /// "扰乱"动画的开始时间,以此来"扰乱"动画的进度,让页面初始化时粒子就在屏幕上均匀分布
  void shuffle() {
    startTime -= (Random().nextDouble() * duration.inMilliseconds)
        .round()
        .milliseconds;
  }

调用时机就是在粒子构造并执行完restart()后,因为我们的startTime初始值是在restart()中设置的:

  ParticleModel() {
    restart();
    shuffle();
  }

改造完后我们会发现应用启动是没问题的👇🏻:
middle_img_v2_e75dc6f0c9b545dcbcb12834f7d0a0eg.gif

但别高兴得太早,我们来试下后台切换,会发现问题依旧👇:
middle_img_v2_6e24b12f9b814ebd8479c69a099ce6dg.gif


后台切换优化

出现这个问题的原因是因为应用在后台休眠的时候,现实的时间还是在流动的,但是因为应用进入了后台后,界面不会再渲染,动画的逻辑完全停止了。如果你听不懂这话也不要紧,最直观的表现就是,当你应用进入后台的时候,Flutter Performance的帧数柱状图会停止,但内存图依旧在动,这并不是发生BUG了,就是应用动画停止了,就像下面这样👇:
QQ20210709101522.gif

接着我们再来回顾下progress()的实现👇:
image.png

很明显,当我们等待30秒后重新进入应用,“预播”的时间早就过去了,因为startTimeduration都是在构造的时候就定好的,而duration最大也就33秒。最终粒子的progress()返回都是1,因为这个原因,后台切回来后所有粒子都通过checkIfParticleNeedsToBeRestarted()触发restart()了而没有触发shuffle(),最终又回到所有粒子都同一起点的问题了。

要解决这个问题也很简单,只要监听前后台切换之后再次调用restartshuffle即可,下面就直接放代码截图吧👇:

image.png

再次尝试等待30秒然后切回应用👇:
middle_img_v2_3606fb49d20e4ef480f3e937b8e8dcag.gif

  • OK,没有问题了

最终只要把渐变背景加上就完整了,渐变背景的代码比较简单,这里就不一一说了,想看最终效果的话直接跑文章开头给出的代码链接吧。

大功告成 🎉

Q.E.D.