Flutter之初識貝塞爾曲線 - 實現炫酷的路由動畫

前言

參考文獻:git

在以前的有一篇文章中實現了仿酷安的主題更改,其實也是當時羣里人隨口一說:「這個看起來好炫酷,能不能用Flutter來實現」,在我眼裏Flutter作東西,絕大部分都是用心均可以實現。github

Flutter炫酷的波紋路由動畫性能優化

因而在發佈上一篇文章後,有人給我評論了一個dribbble上的設計,長這個樣子:bash

第一眼望過去,的確挺炫酷的,目測就比較有難度,反正Flutter有熱重載,首先就開始用用最基本的組件一點一點試。

是否能夠用兩個簡單的圓的動畫來實現?app

我最初想到的也是這個思路,但最後我放棄了(也許你能夠成功哈)。post

緣由:性能

  • (1) 觀察上面的動畫,在動畫到一半也就是中間有一條直線的時候,不難想象,一個圓的能夠大到在手機屏幕內呈直線的話,那麼這個圓的dp應該得上 100,000(測試中的數據),並且永遠都不能達到絕對的直線。學習

  • (2)即便在視覺上給人了直線的感受,拋開性能的開銷,從動畫的層面分析,圓的半徑就是從一個按鈕的大小(假設50dp)擴大到 100,000 ,讓人感知的數據範圍也僅僅在 50dp~1000dp ,而這部分的數據僅僅佔用整個動畫的 1% ,因此這個動畫的時長是極其難控制的。測試

本篇文章實現方案涉及如下數學知識:優化

  • 餘弦定理
  • 貝塞爾曲線
  • 空間座標點的旋轉

其實整個動畫的實現是不會花太多時間的,因爲我是初次學習貝塞爾曲線的使用,還有複習一些高中的知識,還花了些時間。

1、認識貝塞爾曲線

貝塞爾曲線根據控制點的數量分爲:

  • 一階貝塞爾曲線(2 個控制點)
  • 二階貝塞爾曲線(3 個控制點)
  • 三階貝塞爾曲線(4 個控制點)
  • n階貝塞爾曲線(n+1個控制點)

在Flutter的Path類中,每每二階貝塞爾只須要 2 個點做爲參數,三階貝塞爾只須要 3 個點做爲參數,這是一個須要注意的地方,Path默認當前的點爲初始點。

一、一階貝塞爾

每一時刻曲線的點對應公式:

二、二階貝塞爾

每一時刻曲線的點對應公式:

三、三階貝塞爾

每一時刻曲線的點對應公式:

這部分的圖取自文章頂部第一篇參考文章,公式來自百度百科,用動圖能更好的提現出它的軌跡。

2、使用貝塞爾曲線畫圓

因爲這部分的具體原理研究在本文頂部的第二篇參考文獻中寫得很是詳細,咱們主要關心整個動畫的實現。

一、畫出近似 1/4 圓弧

計算參數

咱們使用三階貝塞爾曲線來繪製,須要計算出一個參數 h

圖來自參考文章 2

根據圓的方程與三階貝塞爾曲線的方程便可解出 h 爲 0.552...

固然咱們爲了儘量的準確能夠不將這個值寫死。它的計算表達式。

double h = (math.sqrt(2) - 1.0) * 4.0 / 3.0;
複製代碼

因此在單位圓中,1/4 的圓弧對應的 4 個貝塞爾控制點爲:

(0,1)
(h,1)
(1,h)
(1,0))
複製代碼

這是其中一個方向,隨圓的半徑擴大 h 也隨比例擴大。

畫出這4個點

其中背景網格的代碼來自參考文章 3

根據這4個點畫出圓弧

二、拼接成整個圓

代碼

final List<Offset> _firstControllerPoints = <Offset>[];
 final List<Offset> _secondControllerPoints = <Offset>[];
 final List<Offset> _thirdControllerPoints = <Offset>[];
 final List<Offset> _fourthControllerPoints = <Offset>[];
 double h = (math.sqrt(2) - 1.0) * 4.0 / 3.0;
 void generateControllerPoints(Offset circelCenter, double circleRadius) {
    h = h * circleRadius;
    // ------------------------------
    _firstControllerPoints.add(Offset(
      circelCenter.dx - circleRadius,
      circelCenter.dy,
    ));
    _firstControllerPoints.add(Offset(
      circelCenter.dx - circleRadius,
      circelCenter.dy - h,
    ));
    _firstControllerPoints.add(Offset(
      circelCenter.dx - h,
      circelCenter.dy - circleRadius,
    ));
    _firstControllerPoints.add(Offset(
      circelCenter.dx,
      circelCenter.dy - circleRadius,
    ));
    // ------------------------------
    _secondControllerPoints.add(Offset(
      circelCenter.dx,
      circelCenter.dy - circleRadius,
    ));
    _secondControllerPoints.add(Offset(
      circelCenter.dx + h,
      circelCenter.dy - circleRadius,
    ));
    _secondControllerPoints.add(Offset(
      circelCenter.dx + circleRadius,
      circelCenter.dy - h,
    ));
    _secondControllerPoints.add(Offset(
      circelCenter.dx + circleRadius,
      circelCenter.dy,
    ));
    // ------------------------------
    _thirdControllerPoints.add(Offset(
      circelCenter.dx + circleRadius,
      circelCenter.dy,
    ));
    _thirdControllerPoints.add(Offset(
      circelCenter.dx + circleRadius,
      circelCenter.dy + h,
    ));
    _thirdControllerPoints.add(Offset(
      circelCenter.dx + h,
      circelCenter.dy + circleRadius,
    ));
    _thirdControllerPoints.add(Offset(
      circelCenter.dx,
      circelCenter.dy + circleRadius,
    ));
    // ------------------------------
    _fourthControllerPoints.add(Offset(
      circelCenter.dx,
      circelCenter.dy + circleRadius,
    ));
    _fourthControllerPoints.add(Offset(
      circelCenter.dx - h,
      circelCenter.dy + circleRadius,
    ));
    _fourthControllerPoints.add(Offset(
      circelCenter.dx - circleRadius,
      circelCenter.dy + h,
    ));
    _fourthControllerPoints.add(Offset(
      circelCenter.dx - circleRadius,
      circelCenter.dy,
    ));
  }
  Path getCirclePath() {
    final Path path = Path();
    path.moveTo(
      _firstControllerPoints[0].dx,
      _firstControllerPoints[0].dy,
    );
    path.cubicTo(
      _firstControllerPoints[1].dx,
      _firstControllerPoints[1].dy,
      _firstControllerPoints[2].dx,
      _firstControllerPoints[2].dy,
      _firstControllerPoints[3].dx,
      _firstControllerPoints[3].dy,
    );
    path.cubicTo(
      _secondControllerPoints[1].dx,
      _secondControllerPoints[1].dy,
      _secondControllerPoints[2].dx,
      _secondControllerPoints[2].dy,
      _secondControllerPoints[3].dx,
      _secondControllerPoints[3].dy,
    );
    path.cubicTo(
      _thirdControllerPoints[1].dx,
      _thirdControllerPoints[1].dy,
      _thirdControllerPoints[2].dx,
      _thirdControllerPoints[2].dy,
      _thirdControllerPoints[3].dx,
      _thirdControllerPoints[3].dy,
    );
    path.cubicTo(
      _fourthControllerPoints[1].dx,
      _fourthControllerPoints[1].dy,
      _fourthControllerPoints[2].dx,
      _fourthControllerPoints[2].dy,
      _fourthControllerPoints[3].dx,
      _fourthControllerPoints[3].dy,
    );
    return path;
  }
複製代碼

控制點實際 12 個就足夠了,由於初始點爲當前點在的位置。

3、動畫的實現

一、動畫的分割

我將整個動畫分紅四個部分

右圓動畫:

  • 1.由一個小圓逐漸變大,高度最值能夠恰好撐滿屏幕。
  • 2.將 1/4 的圓弧逐漸拉直,模擬圓繼續擴大的效果。

左圓動畫:

  • 1.將直線逐漸向 1/4 的圓弧過渡。
  • 2.由一個大圓逐漸變小。

二、實現第一部分動畫

第一部分無非是給定圓的半徑慢慢變大,而後並及時改變圓心的位置。

以下:

這時候咱們就須要思考一個問題,咱們應該在什麼時候結束第一段動畫?

在反覆的動畫嘗試後,我將結束的時間,選在了圓恰好擴充到屏幕端點的位置,然而就又有新的問題是:

我如何計算出半徑 r 爲什麼值的時候恰好到達屏幕端點?

對於數學極其自信且好強的我,固然……



最後

高中同窗強烈要求要讓你們知道它貢獻的餘弦定理,我就不砍頭像了。

已知條件:

  • 按鈕中心座標到屏幕端點的距離
  • 按鈕中心座標到與屏幕端點的向量的弧度值

最後用餘弦定理

double getRadius(
    Offset point1,
    Offset point2,
  ) {
    point1 = Offset(point1.dx, -point1.dy);
    point2 = Offset(point2.dx, -point2.dy);
    double r;
    final Offset line = point2 - point1;
    final double k = line.dy / line.dx;
    final double angle = math.atan(k);
    print('line向量座標爲====>$line');
    final double cosx = math.cos(angle);
    print('cosx====>$cosx');
    final double sinx = math.sin(angle);
    print('sinx====>$sinx');
    final double cos2x = math.pow(cosx, 2).toDouble() - math.pow(sinx, 2);
    final double l = line.distance;
    r = math.sqrt(math.pow(l, 2) / (2 * (1 + cos2x)));
    return r;
  }
複製代碼

傳入的 point1 , point2 即爲按鈕中心座標與屏幕頂端座標。

第一部分動畫最後效果:

三、實現第二部分動畫

這部分動畫是耗時最久的,爲了方便動畫設計,我先將第一部分動畫末的圓半徑減少。

咱們經過旋轉四階貝塞爾的末兩個控制點來模擬圓弧的變化。

圖示:

咱們只須要旋轉一組控制點的後兩個或者前兩個,因此涉及到的數學知識就是,咱們須要準確計算出一個點相對另外一個點旋轉指定角度後的位置。

代碼實現:

double getNegative(num number) {
    if (number.isNegative) {
      return -1;
    }
    return 1;
  }

  double getVectorInitAngle(Offset vector) {
    if (vector.dx == 0) {
      if (vector.dy > 0) {
        return math.pi / 2;
      } else {
        return math.pi * 3 / 2;
      }
    }
    if (vector.dy == 0) {
      if (vector.dx > 0) {
        return 0;
      } else {
        return math.pi;
      }
    }
    if (vector.dx > 0 && vector.dy > 0) {
      return math.atan2(vector.dx, vector.dy);
      // print(math.atan2(vector.dx, vector.dy) * 180 / math.pi);
    } else if (vector.dx < 0 && vector.dy > 0) {
      return math.atan2(vector.dx, vector.dy) + math.pi;
      // print(180 + math.atan2(vector.dx, vector.dy) * 180 / math.pi);
    } else if (vector.dx < 0 && vector.dy < 0) {
      return math.pi / 2 - math.atan2(vector.dx, vector.dy);
      // print(90 - math.atan2(vector.dx, vector.dy) * 180 / math.pi);
    } else if (vector.dx > 0 && vector.dy < 0) {
      return math.atan2(vector.dx, vector.dy) + math.pi;
      // print(180 + math.atan2(vector.dx, vector.dy) * 180 / math.pi);
    }
    return 0;
  }

  Offset rotateOffset(Offset point, double angle, [Offset origin]) {
    Offset tmp;
    origin ??= const Offset(0, 0);
    final Offset vector = point - origin;
    if (angle == 0.0) {
      return point;
    }
    print('vector====$vector===初始角度===>${getVectorInitAngle(vector)}');
    print('angle====${angle * 180 / math.pi}');
    tmp = Offset(
      origin.dx +
          vector.distance * math.cos(angle + getVectorInitAngle(vector)),
      origin.dy +
          vector.distance * math.sin(angle + getVectorInitAngle(vector)),
    );
    return tmp;
  }
複製代碼

第二部分動畫最後效果

將前部分動畫加了起來

咱們將須要旋轉的點都設置好,再來看看。

出現了新的問題,這不是咱們想要的效果。

咱們繼續縮小半徑方便觀察,並打印出全部的輔助點。

找出緣由以下:

  • 第二個圓弧的控制點的初位置是用的第一個圓弧的終點,以此類推。
  • 在第二部分的動畫中,部分點隨一個固定點旋轉後,致使使用了這個點的貝塞爾發生預期以外的變化。

這也是上面提到一個圓的貝塞爾控制點只須要 12 個就夠了的緣由,由於有 4 個點是共用的。

解決方案

在由於共用控制點致使路徑不符合預期的點添加一組二階貝塞爾,用來鏈接上一組的終點與下一組的起點。

咱們填上顏色再看看:

使用實際計算的半徑:

整個動畫的難點差很少就完了。

四、實現第3、四部分動畫

這兩部分動畫就不細講了,就是建立一個新的圓,與以前圓的動畫數據所有相反,圓心座標不一樣。

初步實現

加上一些布爾值,當第三部分動畫開始的時候,在左側建立一整個矩形方塊,而後利用路徑相減,同時取消掉紅色的輔助按鈕。

再看效果:

觀察原動畫,路由前、路由頁面的組件都有一個位移的動畫,在左圓即將到最小的時候也有一個位移動畫。

咱們將全部的處理完成。

最終效果

安卓設備上:

ffmpeg 在轉換的時候 gif 變慢了不知道爲啥。另外路由前頁面的位移動畫,與路由後的頁面位移動畫須要另外加了 😑 。

還有羣友提到的細節,我沒能優化,碰見坑了,當前的動畫的最後一部分是以不太優雅的方式實現的 😳 。

4、組件封裝

讓這個路由的使用變得更加容易。

我優先考慮的是封裝成 PageRouteBuilder ,最後因爲涉及過多的動畫,最終將它封裝成了組件的形式。

你只須要在任何位置使用這個按鈕:

CoolButton(
    curPageAccentColor: Color(0xff013bca),
    buttonColor: Color(0xfffcb7d6),
    nextButtonColor: Colors.white,
    pushPage: PushPage(),
),
複製代碼

或者你想另外封裝,在 lib 下 cool_button.dart 中的 CoolAnimation 有整個動畫的實現,包括性能優化與細節處理,都還須要本身改下代碼。

demo 地址:

MYS_Flutter

5、結語

  • 代碼寫得比較趕,不少地方不規範,我在將這個路由引入本身項目後會改進。
  • 上一篇「 炫酷的波紋路由動畫 」是今年 2 月發佈的,與如今對比了寫文章的格式與排版,也算看到了本身這五個月在這方面的進步。
  • 「 MYS_Flutter 」初次建立是在兩年前了,裏面的其餘 demo 的最後一次更新也是兩年前😶 。
  • 本身手上沒有各類各樣的項目,因此在學 Flutter 的時候不須要成天組各類各樣的輪子,對我而言,我永遠喜歡嘗試新的東西,這也是我嘗試了 Flutter 的緣由。

happy coding !

相關文章
相關標籤/搜索