Flutter動畫實現粒子漂浮效果

夢龍Dragon發表於2019-10-06
本文所有原始碼見github.com/MoonRiser/F…

要問2019年最火的移動端框架,肯定非Google的Flutter莫屬。

image
本著學習的態度,基本的Dart語法(個人感覺語法風格接近Java+JS)過完之後,開始擼程式碼練手。


效果圖

image

粒子碰撞的效果參考了張風捷特列 大佬的Flutter動畫之粒子精講

1. Flutter的動畫原理

在任何系統的UI框架中,動畫實現的原理都是相同的,即:在一段時間內,快速地多次改變UI外觀;由於人眼會產生視覺暫留,所以最終看到的就是一個“連續”的動畫,這和電影的原理是一樣的。我們將UI的一次改變稱為一個動畫幀,對應一次螢幕重新整理,而決定動畫流暢度的一個重要指標就是幀率FPS(Frame Per Second),即每秒的動畫幀數。

簡而言之,就是逐幀繪製,只要螢幕重新整理的足夠快,我們就會覺得這是個連續的動畫。 設想一個小球從螢幕頂端移動到底端的動畫,為了完成這個動畫,我們需要哪些資料呢?

  • 小球的運動軌跡,即起始點s、終點e和中間任意一點p
  • 動畫持續時長t

只有這兩個引數夠嗎?明顯是不夠的,因為小球按照給定的軌跡運動,可能是勻速、先快後慢、先慢後快、甚至是一會兒快一會慢的交替地運動,只要在時間t內完成,都是可能的。所以我們應該再指定一個引數c來控制動畫的速度。

1.1 vsync是啥

廢話不多說,我們看看Flutter中是動畫部分的程式碼:

AnimationController controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 2),
    )..addListener(() {
        //_renderBezier();
        print(controllerG.value);
        print('這是第${++count}次回撥');
      });
複製程式碼

簡要分析一下,AnimationController,顧名思義,控制器,用來控制動畫的播放。傳入的引數中,duration我們知道是前面提到過的動畫持續時長t,那這個vsync是啥引數呢?打過遊戲的同學可能對這個單詞有印象,vsync 就是 垂直同步 。那什麼是垂直同步呢?

垂直同步又稱場同步(Vertical Hold),從CRT顯示器的顯示原理來看,單個畫素組成了水平掃描線,水平掃描線在垂直方向的堆積形成了完整的畫面。顯示器的重新整理率受顯示卡DAC控制,顯示卡DAC完成一幀的掃描後就會產生一個垂直同步訊號。我們平時所說的開啟垂直同步指的是將該訊號送入顯示卡3D圖形處理部分,從而讓顯示卡在生成3D圖形時受垂直同步訊號的制約。

簡而言之就是,顯示卡在完成渲染後,將畫面資料送往視訊記憶體中,而顯示器從視訊記憶體中一行一行從上到下取出畫面資料,進行顯示。但是螢幕的重新整理率和顯示卡渲染資料的速度很多時候是不匹配的,試想一下,顯示器剛掃描顯示完螢幕上半部分的畫面,正準備從視訊記憶體取下面的畫面資料時,顯示卡送來了下一幀的影象資料覆蓋了原來的視訊記憶體,這個時候顯示器取出的下面部分的影象就和上面的不匹配,造成畫面撕裂。 為了避免這種情況,我們引入垂直同步訊號,只有在顯示器完整的掃描顯示完一幀的畫面後,顯示卡收到垂直同步訊號才能重新整理視訊記憶體。 可是這個物理訊號跟我們flutter的動畫有啥關係呢?vsync對應的引數是this,我們繼續分析一下this對應的下面的類。

class _RunBallState extends State<RunBall> with TickerProviderStateMixin 
複製程式碼

with關鍵字是使用該類的方法而不繼承該類,Mixin是類似於Java的介面,區別在於Mixin中的方法不是抽象方法而是已經實現了的方法。

這個TickerProviderStateMixin到底是幹啥的呢???經過哥們兒Google的幫助,在網上找到了

關於動畫的驅動,在此簡單的說一下,Ticker是被SchedulerBinding所驅動。SchedulerBinding則是監聽著Window.onBeginFrame回撥。 Window.onBeginFrame的作用是什麼呢,是告訴應用該提供一個scene了,它是被硬體的VSync訊號所驅動的。

於是我們終於發現了,繞了一圈,歸根到底還是真正的硬體產生的垂直同步訊號在驅動著Flutter的動畫的進行。

..addListener(() {
        //_renderBezier();
        print(controllerG.value);
        print('這是第${++count}次回撥');
      });

複製程式碼

注意到之前的程式碼中存在一個動畫控制器的監聽器,動畫在執行時間內,函式回撥controller.value會生成一個從0到1的double型別的數值。我們在控制檯列印出結果如下:

image

image

經過觀察,兩次試驗,在2s的動畫執行時間內,該回撥函式分別被執行了50次,53次,並不是一個固定值。也就是說硬體(模擬器)的螢幕重新整理率大概維持在(25~26.5幀/s)。

結論:硬體決定動畫重新整理率

1.2 動畫動起來

搞懂了動畫的原理之後,我們接下來就是逐幀的繪製了。關於Flutter的自定義View,跟android原生比較像。

image

繼承CustomPainter類,重寫paint和shouldRepaint方法,具體實現可以看程式碼.

class Ball {
  double aX;
  double aY;
  double vX;
  double vY;
  double x;
  double y;
  double r;
  Color color;}

複製程式碼

小球Ball具有圓心座標、半徑、顏色、速度、加速度等屬性,通過數學表示式計算速度和加速度的關係,就可以實現勻加速的效果。

//運動學公式,看起來少了個時間t;實際上這些函式在動畫過程中逐幀回撥,把每幀重新整理週期當成單位時間,相當於t=1
    _ball.x += _ball.vX;//位移=速度*時間
    _ball.y += _ball.vY;
    _ball.vX += _ball.aX;//速度=加速度*時間
    _ball.vY += _ball.aY;

複製程式碼

控制器使得函式不斷回撥,在回撥函式函式裡改變小球的相關引數,並且呼叫setState()函式,使得UI重新繪製;小球的軌跡座標不斷地變化,逐幀繪製的小球看起來就在運動了。你甚至可以在新增效果使得小球在撞到邊界時變色或者半徑變小(參考文章開頭的粒子碰撞效果圖)。

2. 小球如何隨機浮動

問題來了,我想要一個漂浮的效果呢?最好是隨機的軌跡,就像氣泡在空中飄乎不定,於是引起了我的思考;勻速然後方向隨機?感覺不夠優雅,於是去網上搜了一下,發現了思路!

首先隨機生成一條貝塞爾曲線作為軌跡,等小球運動到終點,再生成新的貝塞爾曲線軌跡

生成二階貝塞爾曲線的公式如下:

//二次貝塞爾曲線軌跡座標,根據引數t返回座標;起始點p0,控制點p1,終點p2
Offset quadBezierTrack(double t, Offset p0, Offset p1, Offset p2) {
  var bx = (1 - t) * (1 - t) * p0.dx + 2 * t * (1 - t) * p1.dx + t * t * p2.dx;
  var by = (1 - t) * (1 - t) * p0.dy + 2 * t * (1 - t) * p1.dy + t * t * p2.dy;

  return Offset(bx, by);
}
複製程式碼

很巧的是,這裡需要傳入一個0~1之間double型別的引數t,恰好前面我們提過,animationController會在給定的時間內,生成一個0~1的value;這太巧了。

起始點的座標不用說,接下來就剩解決控制點p1和p2,當然是隨機生成這兩點,但是如果同時有多個小球呢?比如5個小球同時進行漂浮,每個小球都對應一組三個座標的資訊,給小球Ball新增三個座標的屬性?不,這個時候,我們可以巧妙地利用帶種子引數的隨機數。

我們知道隨機數在生成的時候,如果種子相同的話,每次生成的隨機數也是相同的。

每個小球物件在建立的時候自增地賦予一個整形的id,作為隨機種子;比如5個小球,我們起始的id為:2,4,6,8,10;

    Offset p0 = ball.p0;//起點座標
    Offset p1 = _randPosition(ball.id);
    Offset p2 = _randPosition(ball.id + 1);
複製程式碼

rand(2),rand(2+1)為第一個小球的p1和p2座標;當所有小球到達終點時,此時原來的終點p2為新一輪貝塞爾曲線的起點;此時相應的id也應增加,為了防止重複,id應增加小球數量5 *2,即第二輪運動開始時,5個小球的id為:12,14,16,18,20。 這樣就保證了每輪貝塞爾曲線運動的時候,對於每個小球而言,p0,p1,p2是確定的;新一輪的運動所需要的隨機的三個座標點,只需要改變id的值就好了。

Path quadBezierPath(Offset p0, Offset p1, Offset p2) {
  Path path = new Path();
  path.moveTo(p0.dx, p0.dy);
  path.quadraticBezierTo(p1.dx, p1.dy, p2.dx, p2.dy);
  return path;
}
複製程式碼

這個時候,我們還可以利用Flutter自帶的api畫出二次貝塞爾曲線的軌跡,看看小球的運動是否落在軌跡上。

image

2.1 一些細節

animation = CurvedAnimation(parent: controllerG, curve: Curves.bounceOut);
複製程式碼

這裡的Curve就是前面提到的,控制動畫過程的引數,flutter自帶了挺多效果,我最喜歡這個bounceOut(彈出效果)

image

 animation.addStatusListener((status) {
      switch (status) {
        case AnimationStatus.dismissed:
          // TODO: Handle this case.
          break;
        case AnimationStatus.forward:
          // TODO: Handle this case.
          break;
        case AnimationStatus.reverse:
          // TODO: Handle this case.
          break;
        case AnimationStatus.completed:
          // TODO: Handle this case.
          controllerG.reset();
          controllerG.forward();
          break;
      }
    });

複製程式碼

監聽動畫過程的狀態,當一輪動畫結束時,status狀態為AnimationStatus.completed;此時,我們將控制器reset重置,再forward重新啟動,此時就會開始新一輪的動畫效果;如果我們選的是reverse,則動畫會反向播放。


GestureDetector(
          child: Container(
            width: double.infinity,
            height: 200,
            child: CustomPaint(
              painter: FloatBallView(_ballsF, _areaF),
            ),
          ),
          onTap: () {
            controllerG.forward();
          },
          onDoubleTap: () {
            controllerG.stop();
          },
        ),
複製程式碼

為了方便控制,我還加了個手勢監聽器,單擊控制動畫執行,雙擊暫停動畫。

3 完結

水平有限,文中如有錯誤還請各位指出,我是夢龍Dragon

image

相關文章