前言
原本是想接着上次的简单爆炸效果,找个代码写一篇Widget点击爆炸+粒子曲线掉落的教程的,但是因为我数学比较渣,完全看不懂作者的一堆常量+组合是配哪个公式(作者本身也没有任何注释),所以还是等到周末的时候再看看吧。
这次就先来实现一个冒泡背景吧,效果图👇:
- 受Gif限制,帧数看起来有点低,实际帧数可以稳在59~60
原文教程链接:传送门。没错,跟简单爆炸是同一个作者。
本文额外修复了作者的一个问题,即后台切换优化。
完整代码:https://gitee.com/wenjie2018/flutter_bubble_demo
Flutter SDK: 2.2.2
dependencies: simple_animations: ^3.0.1
从一个点开始
我认为任何一个复杂动画都是通过不同单元动画组合而成的,所以接下来我还是会从一个粒子的动画逐步过渡到整个完整动画。
在第一个粒子诞生之前,我们先拟定好一些规则,如下图所示👇:
- 偏移量是基于屏幕左上角开始的计算的,所以x是向右递增,y是向下递增。这点你可以根据自己的喜好调整。
- 虚拟边界存在的意义是:粒子开始时可以从屏幕外诞生并进入屏幕内,结束时从屏幕外消失,这样就不会出现凭空诞生/消失的诡异粒子。
单点固定动画
好了,接下来我们先实现一个粒子从虚拟边界底部->顶部的简单动画,思路是这样的:
- 构建循环动画区间——
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();
}
}
效果如下👇:
粒子随机大小
这个很简单,给restart
方法中的粒子大小加上随机就可以了,为了防止粒子大小为0,可以设置一个固定值+随机值
restart() {
...(略)
// 粒子大小
size = 0.2 + Random().nextDouble() * 0.4;
}
效果如下,出的粒子大小变了👇:
随机起/终点
为了让粒子不只是直直的移动,我们需要将起点的x坐标和终点的x坐标随机化,y坐标还是原来的1.2->-0.2不用变:
改动的函数还是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);
...(略)
}
改造后效果如下👇:
增加粒子
接下来的事情就比较简单了,首先我们把原本的1个粒子改成50个,对应代码如下:
...(略)
Positioned.fill(child: ParticlesWidget(50))
现在效果如下👇:
上面的效果看起来有点魔幻,那是因为我们每个粒子的动画过渡时间都是相等的,为了让粒子分布更均匀,我们把动画的过渡时间也加上随机值,代码改动如下👇:
restart() {
// 动画过渡时间
duration = 3.seconds + Random().nextInt(30000).milliseconds;
...(其余代码略)
}
改造后,待app启动10秒+后效果如下👇:
- 已经有内味了,但是别急,还有可以优化的地方
启动优化
细心的小伙伴可能已经注意到了,我上面特地加粗了待app启动10秒+后,这是因为这个动画刚打开的时候是这样的👇:
又或者你把应用切到后台,等大概30秒左右,再切回来,会发现动画又被重置了,就像下面这样👇:
发生这种现象的原因其实就是我们让所有粒子的起点时间相同了,那么怎么做才能让应用启动的时候就让部分粒子的动画“偷跑”呢?其实很简单,我们现在的动画是通过progress()
函数获取动画的进度(这个方法返回值在0~1之间,也就是相当于当前动画进度的百分比),然后根据进度值获取补间值(Tween)的。也就是说,我们只需要改变进度,就能改变动画绘制的起始位置了,比如当某个粒子的progress()
返回值为0.5时,它必定在屏幕y轴的中间位置。
影响progress()
的参数有startTime
和duration
,显然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();
}
改造完后我们会发现应用启动是没问题的👇🏻:
但别高兴得太早,我们来试下后台切换,会发现问题依旧👇:
后台切换优化
出现这个问题的原因是因为应用在后台休眠的时候,现实的时间还是在流动的,但是因为应用进入了后台后,界面不会再渲染,动画的逻辑完全停止了。如果你听不懂这话也不要紧,最直观的表现就是,当你应用进入后台的时候,Flutter Performance
的帧数柱状图会停止,但内存图依旧在动,这并不是发生BUG了,就是应用动画停止了,就像下面这样👇:
接着我们再来回顾下progress()
的实现👇:
很明显,当我们等待30秒后重新进入应用,“预播”的时间早就过去了,因为startTime
和duration
都是在构造的时候就定好的,而duration
最大也就33秒。最终粒子的progress()
返回都是1,因为这个原因,后台切回来后所有粒子都通过checkIfParticleNeedsToBeRestarted()
触发restart()
了而没有触发shuffle()
,最终又回到所有粒子都同一起点的问题了。
要解决这个问题也很简单,只要监听前后台切换之后再次调用restart
、shuffle
即可,下面就直接放代码截图吧👇:
再次尝试等待30秒然后切回应用👇:
- OK,没有问题了
最终只要把渐变背景加上就完整了,渐变背景的代码比较简单,这里就不一一说了,想看最终效果的话直接跑文章开头给出的代码链接吧。
大功告成 🎉