前言
原本是想接著上次的簡單爆炸效果,找個程式碼寫一篇Widget點選爆炸+粒子曲線掉落的教程的,但是因為我數學比較渣,完全看不懂作者的一堆常量+組合是配哪個公式(作者本身也沒有任何註釋),所以還是等到週末的時候再看看吧。
這次就先來實現一個冒泡背景吧,效果圖?:
- 受Gif限制,幀數看起來有點低,實際幀數可以穩在59~60
原文教程連結:傳送門。沒錯,跟簡單爆炸是同一個作者。
本文額外修復了作者的一個問題,即後臺切換優化。
完整程式碼:gitee.com/wenjie2018/…
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,沒有問題了
最終只要把漸變背景加上就完整了,漸變背景的程式碼比較簡單,這裡就不一一說了,想看最終效果的話直接跑文章開頭給出的程式碼連結吧。
大功告成 ?