Flutter實現冒泡背景

eee發表於2021-07-09

前言

原本是想接著上次的簡單爆炸效果,找個程式碼寫一篇Widget點選爆炸+粒子曲線掉落的教程的,但是因為我數學比較渣,完全看不懂作者的一堆常量+組合是配哪個公式(作者本身也沒有任何註釋),所以還是等到週末的時候再看看吧。

這次就先來實現一個冒泡背景吧,效果圖?:

middle_img_v2_4b5e998b-f376-40b4-89c7-e29a1c25de4g

  • 受Gif限制,幀數看起來有點低,實際幀數可以穩在59~60

QQ20210709021230.gif

原文教程連結:傳送門。沒錯,跟簡單爆炸是同一個作者。
本文額外修復了作者的一個問題,即後臺切換優化。
完整程式碼:gitee.com/wenjie2018/…
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,沒有問題了

最終只要把漸變背景加上就完整了,漸變背景的程式碼比較簡單,這裡就不一一說了,想看最終效果的話直接跑文章開頭給出的程式碼連結吧。

大功告成 ?

相關文章