參考文獻:git
在以前的有一篇文章中實現了仿酷安的主題更改,其實也是當時羣里人隨口一說:「這個看起來好炫酷,能不能用Flutter來實現」,在我眼裏Flutter作東西,絕大部分都是用心均可以實現。github
Flutter炫酷的波紋路由動畫性能優化
因而在發佈上一篇文章後,有人給我評論了一個dribbble上的設計,長這個樣子:bash
第一眼望過去,的確挺炫酷的,目測就比較有難度,反正Flutter有熱重載,首先就開始用用最基本的組件一點一點試。是否能夠用兩個簡單的圓的動畫來實現?app
我最初想到的也是這個思路,但最後我放棄了(也許你能夠成功哈)。post
緣由:性能
(1) 觀察上面的動畫,在動畫到一半也就是中間有一條直線的時候,不難想象,一個圓的能夠大到在手機屏幕內呈直線的話,那麼這個圓的dp應該得上 100,000(測試中的數據),並且永遠都不能達到絕對的直線。學習
(2)即便在視覺上給人了直線的感受,拋開性能的開銷,從動畫的層面分析,圓的半徑就是從一個按鈕的大小(假設50dp)擴大到 100,000 ,讓人感知的數據範圍也僅僅在 50dp~1000dp ,而這部分的數據僅僅佔用整個動畫的 1% ,因此這個動畫的時長是極其難控制的。測試
本篇文章實現方案涉及如下數學知識:優化
其實整個動畫的實現是不會花太多時間的,因爲我是初次學習貝塞爾曲線的使用,還有複習一些高中的知識,還花了些時間。
貝塞爾曲線根據控制點的數量分爲:
在Flutter的Path類中,每每二階貝塞爾只須要 2 個點做爲參數,三階貝塞爾只須要 3 個點做爲參數,這是一個須要注意的地方,Path默認當前的點爲初始點。
每一時刻曲線的點對應公式:
每一時刻曲線的點對應公式:
每一時刻曲線的點對應公式:
這部分的圖取自文章頂部第一篇參考文章,公式來自百度百科,用動圖能更好的提現出它的軌跡。
因爲這部分的具體原理研究在本文頂部的第二篇參考文獻中寫得很是詳細,咱們主要關心整個動畫的實現。
計算參數
咱們使用三階貝塞爾曲線來繪製,須要計算出一個參數 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 個就足夠了,由於初始點爲當前點在的位置。
我將整個動畫分紅四個部分
右圓動畫:
左圓動畫:
第一部分無非是給定圓的半徑慢慢變大,而後並及時改變圓心的位置。
以下:
這時候咱們就須要思考一個問題,咱們應該在什麼時候結束第一段動畫?
在反覆的動畫嘗試後,我將結束的時間,選在了圓恰好擴充到屏幕端點的位置,然而就又有新的問題是:
我如何計算出半徑 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 個點是共用的。
解決方案
在由於共用控制點致使路徑不符合預期的點添加一組二階貝塞爾,用來鏈接上一組的終點與下一組的起點。
咱們填上顏色再看看:
使用實際計算的半徑:
整個動畫的難點差很少就完了。
這兩部分動畫就不細講了,就是建立一個新的圓,與以前圓的動畫數據所有相反,圓心座標不一樣。
初步實現
加上一些布爾值,當第三部分動畫開始的時候,在左側建立一整個矩形方塊,而後利用路徑相減,同時取消掉紅色的輔助按鈕。
再看效果:
觀察原動畫,路由前、路由頁面的組件都有一個位移的動畫,在左圓即將到最小的時候也有一個位移動畫。
咱們將全部的處理完成。
最終效果
安卓設備上:
ffmpeg 在轉換的時候 gif 變慢了不知道爲啥。另外路由前頁面的位移動畫,與路由後的頁面位移動畫須要另外加了 😑 。
還有羣友提到的細節,我沒能優化,碰見坑了,當前的動畫的最後一部分是以不太優雅的方式實現的 😳 。
讓這個路由的使用變得更加容易。
我優先考慮的是封裝成 PageRouteBuilder ,最後因爲涉及過多的動畫,最終將它封裝成了組件的形式。
你只須要在任何位置使用這個按鈕:
CoolButton(
curPageAccentColor: Color(0xff013bca),
buttonColor: Color(0xfffcb7d6),
nextButtonColor: Colors.white,
pushPage: PushPage(),
),
複製代碼
或者你想另外封裝,在 lib 下 cool_button.dart 中的 CoolAnimation 有整個動畫的實現,包括性能優化與細節處理,都還須要本身改下代碼。
demo 地址:
happy coding !