Flutter 貝塞爾曲線動畫的實現思路(水滴分頁指示器)

語言: CN / TW / HK

theme: cyanosis highlight: androidstudio


起因

69b7d63agw1evxh2kw1bcg20m80go1l2.gif

設計圖地址

我打算給分頁加一個好看的動畫效果,想起以前有看到過水滴樣式的分頁指示器,但網上的樣例不是很多。

某天找到掘金的這篇文章--# Flutter 自定義元件之貝塞爾曲線繪製波浪球 感覺效果不錯,但苦於文章講的不是很細,所以沒能很快的理解實現的方法,於是在經過幾天的摸索後完成的動效的實現,程式碼在mochixuan原始碼的基礎上修改的,但基本上計算部分全是我自己的思路,並附上了詳細的註釋

如果你一開始也對設計圖的效果不知道怎麼實現,不妨跟著這篇文章一起走下去,相信看完你就能知道如果做出一模一樣的動畫。(本文用大量的GIF動畫讓你輕鬆理解)

先看完成效果

目前顏色透明漸變.gif

分析

首先肯定是先分析一下設計圖裡的一些細節效果主要分為上下兩部分

分頁.gif

分頁

分頁指示器.gif

分頁指示器

分頁部分不用多少,就是普通的PageView,這裡的難點是如何精確判斷: - [ ] 當前是哪個頁面 - [ ] 當前是左滑還是右滑 - [ ] 當前滑動的進度

分頁指示器是我們動畫的重點,要求: - [ ] 水滴對應當前分頁 - [ ] 頁數遞增,水滴被向右拉伸 - [ ] 頁數遞減,水滴被向左拉伸 - [ ] 水滴有回彈填充的效果 - [ ] 顏色對應分頁 - [ ] 顏色變化存在透明度的變化 - [ ] 點選分頁指示器的水滴可以直接跳轉到對應的分頁

實現

分頁部分

我們先實現分頁部分,這部分直接貼原始碼

變數

``` ///分頁控制器 late PageController pageController;

///分頁色彩 List colors = [ Colors.red, Colors.deepOrange, Colors.amber, Colors.blue, Colors.deepPurpleAccent ];

```

監聽

``` @override void initState() { super.initState();

///設定係數比例為0.8 pageController = PageController(viewportFraction: 0.8);

pageController.addListener((){

setState(() {});

}); }

```

繪製部分

``` @override Widget build(BuildContext context) { return Container( color: Color.fromRGBO(20, 26, 36, 1),///統一背景色 child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 220, color: Colors.lightGreen, child: PageView.builder( itemBuilder: (context,index){ ///新增邊距,顯示效果為長矩形 return Container( margin: EdgeInsets.all(8.0), height: 220, child: Card( elevation: 10, color: colors[index], shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), ); }, itemCount: 5, scrollDirection: Axis.horizontal, reverse: false, controller: pageController, physics: const PageScrollPhysics(parent: BouncingScrollPhysics()), ), ), ], ), ); }

```

到目前,我們已經實現了分頁部分,效果如下:

第一分頁部分.gif

指示器部分

背景線框

然後我們新增指示器部分的背景線框,並且去掉分頁部分的背景色

///新增指示器的背景圓形線框 List<Widget> backgroundWireframe(){ List<Widget> widgets = []; while(widgets.length < 5){ widgets.add( Container( width: radius*2, height: radius*2, decoration: BoxDecoration( border: Border.all(color: Colors.white,width: 1,style: BorderStyle.solid), borderRadius: BorderRadius.all(Radius.circular(20)) ), ), ); } return widgets; }

image.png

繪製第一個水滴圓

所需知識

繪製圓所需要的貝塞爾曲線知識,可以參考這篇文章 如何理解並應用貝塞爾曲線

所用軟體

然後有請我們的繪製軟體GeoGebar隆重登場(免費)

附上官方連結GeoGebra - 風靡世界, 過億師生沉迷使用的免費數學軟體

曲線畫圓思路

主要思路也是把圓以圓心的座標系分成4段曲線

image.png

  • 第一段:P1-P2     控制點:P1R和P2L
  • 第二段:P2-P3     控制點:P2R和P3R
  • 第三段:P3-P4     控制點:P3L和P4L
  • 第四段:P4-P1     控制點:P4R和P1L

image.png 圖中所有標記為紅色和綠色的點都是和進度引數掛鉤,8個控制點和M係數掛鉤,也就是說8個控制桿(例如線段f=P1-P1R)的長度和M的值是一至的

當M = 1 的時候,我們開啟Z,A1,B1,C1這四個點的軌跡,並且開啟進度動畫時候,我們能看到一個由4個3階貝塞爾曲線形成的圓角矩形

M為0的軌跡.gif

M = 0.552的時候

M為0.552的近似圓.gif

通過畫圖或者公式我們可以得知M的係數在0.552...左右的時候,已接近1/4個圓弧

所以我們得到了轉換後的8個控制點座標,當然在手機上的座標系是這樣的(紅色座標系)

image.png

在我們知道所有的點的時候就可以畫水滴的初始狀態(圓)

接下來,我們定義Point方便管理座標 class Point { double x; double y; Point({required this.x,required this.y}); } 自定義BaseView繼承CustomPainter ``` class BaseView extends CustomPainter{

final double radius; final double M = 0.551915024494;

late Paint curvePaint; late Path curvePath;

BaseView({ required this.radius, }){ curvePaint = Paint() ..style = PaintingStyle.fill; curvePath = Path(); }

@override void paint(Canvas canvas, Size size) { curvePath.reset(); curvePaint.color = Colors.deepOrange; _canvasBesselPath(curvePath); canvas.drawPath(curvePath, curvePaint); }

void _canvasBesselPath(Path path) {

///控制點的位置,半徑的0.552倍左右,這時候是近似圓,所以我們從0.552倍的比例開始
double tangentLineLength = radius*M;

///頂端
Point p1 = Point(x: radius,y: 0);
///右邊
Point p2 = Point(x: radius*2,y: radius);
///底端
Point p3 = Point(x: radius,y: radius*2);
///左邊
Point p4 = Point(x: 0,y: radius);

///頂端左右控制點
Point p1L = Point(x: radius - tangentLineLength,y: 0);
Point p1R = Point(x: radius + tangentLineLength,y: 0);

///右邊左右控制點
Point p2L = Point(x: radius*2,y: radius - tangentLineLength);
Point p2R = Point(x: radius*2,y: radius + tangentLineLength);

///底端左右控制點
Point p3L = Point(x: radius - tangentLineLength,y: radius*2);
Point p3R = Point(x: radius + tangentLineLength,y: radius*2);

///左邊左右控制點
Point p4L = Point(x: 0,y: radius + tangentLineLength);
Point p4R = Point(x: 0,y: radius - tangentLineLength);

///所有點都確定位置後,開始繪製連線
///先從原點移動到第一個點P1
path.moveTo(p1.x, p1.y);

///順時針一起連線點,p1-p1R-p2L-p2
path.cubicTo(
    p1R.x, p1R.y,
    p2L.x, p2L.y,
    p2.x, p2.y
);

///p2-p2R-p3R-p3
path.cubicTo(
    p2R.x, p2R.y,
    p3R.x, p3R.y,
    p3.x, p3.y
);

///p3-p3L-p4L-p4
path.cubicTo(
    p3L.x, p3L.y,
    p4L.x, p4L.y,
    p4.x, p4.y
);

///p4-p4R-p1L-p1
path.cubicTo(
    p4R.x, p4R.y,
    p1L.x, p1L.y,
    p1.x, p1.y
);

}

@override bool shouldRepaint(CustomPainter oldDelegate) => true;

} ```

回到IndicatorPage新增我們自定義的BaseView

建立圓的半徑 ///半徑 double radius = 20.0;

``` @override Widget build(BuildContext context) {

///獲取當前螢幕的寬度 double deviceWidth = MediaQuery.of(context).size.width;

return Container( color: Color.fromRGBO(20, 26, 36, 1),///統一背景色 child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container(...), Stack( children: [ Container( padding: EdgeInsets.only(left: deviceWidth0.1,right: deviceWidth0.1), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: backgroundWireframe(),///背景線圓 ), ), Positioned(///改動這裡 child: Transform.translate( offset: Offset(deviceWidth0.1, 0), child: CustomPaint( painter: BaseView( radius: radius, ), ), ), ) ], ), ], ), ); } ``` 這裡deviceWidth0.1是距離螢幕兩邊的距離,執行一下,我們可以看到

image.png

隨著分頁移動無形變

這裡需要算出位移的距離

建立新的變數

///當前頁碼,小數代表進度 double nowCurPosition = 0.0; 在pageController的監聽裡做判斷 ``` pageController.addListener((){ ///當前page資料 nowCurPosition = pageController.page!;

setState(() {});

}); 如果在監聽裡看一下pageController.page的輸出結果 flutter: 當前進度為:2.9552457398989693 flutter: 當前進度為:2.9615092642594383 flutter: 當前進度為:2.9668996098515557 flutter: 當前進度為:2.971537240574408 flutter: 當前進度為:2.9755263780792167 flutter: 當前進度為:2.978957463541294 flutter: 當前進度為:2.9819084092582804 flutter: 當前進度為:2.9844460200168785 flutter: 當前進度為:2.986627916776723 flutter: 當前進度為:2.988504081991054 flutter: 當前進度為:2.9901171777027957 flutter: 當前進度為:2.9938564939421117 flutter: 當前進度為:3.0942838280673373 flutter: 當前進度為:3.2438564939421117 flutter: 當前進度為:3.3138557736880037 flutter: 當前進度為:3.385138178759749 flutter: 當前進度為:3.454112968783686 flutter: 當前進度為:3.518734944707227 flutter: 當前進度為:3.577976816481746 flutter: 當前進度為:3.631456079768243 flutter: 當前進度為:3.6792017478506662 flutter: 當前進度為:3.7214690642413744 flutter: 當前進度為:3.758654772605912 flutter: 當前進度為:3.7912087737092337 ``` 我們可以看出整數位是頁數,小數位是當前頁面的滑動進度

image.png

///位移的距離,相對於起始位置 double offSetX = deviceWidth*0.1+(deviceWidth - radius*2 - deviceWidth*0.2)*nowCurPosition/4;

把得到的offSetX代入Transform的offset後,我們得到了沒有形變的指示器

無形變指示器.gif

隨著分頁移動有形變

當我們只整體移動P2L-P2-P2R這條線段,我設定了2個點,一個2倍半徑,一個3倍半徑,看看效果

image.png 這可以當做指示器右移的時候的形變,同樣左移就是P4L-P4-P4R的左移

image.png

我們只需要知道當前是左移還是右移位移的進度,就可以整體移動左右線段來體現形變拉伸的效果

那麼在IndicatorPage裡新建變數

``` ///上一次的page double oldCurPosition = 0.0;

///是否是向右 bool isToRight = true; ``` 在pageController的監聽裡新增判斷

``` ///比對上一次來判斷左滑還是右滑 if (nowCurPosition > oldCurPosition) { isToRight = true; // debugPrint('往左滑'); } else { isToRight = false; // debugPrint('往右滑'); }

///比對結束賦值 oldCurPosition = nowCurPosition; ```

在BaseView裡,我們要傳入2個欄位,進度和判斷右滑左滑

final double percent; final bool isToRight;

在具體繪製函式_canvasBesselPath裡我們做進度轉化,按照50%的進度為分界線,拉伸和恢復

``` ///位移距離 double displacementDistance = radius;

///漲就是位移的距離長,縮就是位移的距離短,速率要一致(倍數) if (isToRight) {///判斷左劃右劃

///先漲後縮 if (percent > 0 && percent <= 0.5) {

///座標右移,原本的位置 + 位移距離✖進度
p2.x = radius*2 + displacementDistance*percent;
p2L.x = radius*2 + displacementDistance*percent;
p2R.x =radius*2 + displacementDistance*percent;

}else if (percent > 0.5 && percent < 1.0) {

///座標恢復,原本的位置 + 位移距離✖係數,係數為: 0.5 ~ 0
p2.x = p2.x + displacementDistance*(1 - percent);
p2L.x = p2L.x + displacementDistance*(1 - percent);
p2R.x = p2R.x + displacementDistance*(1 - percent);

} } else {

///先漲後縮 if (percent > 0 && percent <= 0.5) {

///座標左移,原本的位置 - 位移距離✖進度
p4.x = p4.x - displacementDistance*percent;
p4L.x = p4L.x - displacementDistance*percent;
p4R.x = p4R.x - displacementDistance*percent;

}else if (percent > 0.5 && percent < 1.0) {

///座標恢復,原本的位置 - 位移距離✖係數,係數為: 0.5 ~ 0
p4.x = p4.x - displacementDistance*(1 - percent);
p4L.x = p4L.x - displacementDistance*(1 - percent);
p4R.x = p4R.x - displacementDistance*(1 - percent);

} } ```

看一下效果

無放大係數.gif

放大拉伸效果

我們可以簡單的加大拉伸(放大係數),之前程式碼裡,我們只是按照增加一倍半徑。

``` ///拉伸係數 double stretch = 2;

///位移距離 double displacementDistance = radius*stretch; ```

2倍半徑放大.gif

目前還沒編輯完,動圖太多了。預計明天能搞完。

X軸回彈效果

我們認真看效果圖裡,接近拉伸完畢後反方向會有回彈的效果

Untitled.gif

就是反方向的控制桿往圓心先接近後恢復的效果

在設定座標之前設定一些變數,這裡的回彈都是進度接近完畢的時候,所以我選擇了 0.9 ~ 1.0

``` ///回彈係數,乘以4是為了回彈效果明顯一點,數字越大效果越明顯) double rebound = 4;

///回彈效果的左右壓縮的距離,因為是從80%開始縮排遞增,所以要percent - 0.9 double leftAndRightIndentedDistance = displacementDistance(percent - 0.9)rebound;

///回彈效果的左右恢復的距離,因為是回彈需要遞減,而percent是遞增,所以要1 - percent double leftAndRightReboundDistance = displacementDistance(1 - percent)rebound; 在**右滑**進度**percent > 0.5 && percent < 1.0**裡操作 ///在進度末尾的時候完成回彈效果,另一邊的點,先縮後恢復 if(percent >= 0.9 && percent < 0.95){

///第一步,縮,比例為:0 ~ 0.2 ///因為是點P4,起始X座標為0,所以X軸向右位移,加就等於縮 p4.x = leftAndRightIndentedDistance; p4L.x = leftAndRightIndentedDistance; p4R.x = leftAndRightIndentedDistance; // debugPrint('縮排距離:$leftAndRightIndentedDistance\n');

}else if( percent >= 0.95){

///第二步,恢復,比例為:0.2 ~ 0 ///恢復其實就是向右位移的距離逐步減少 ///比例為:0.2 ~ 0,這裡的倍數要和之前縮的倍數一致 p4.x = leftAndRightReboundDistance; p4L.x = leftAndRightReboundDistance; p4R.x = leftAndRightReboundDistance; // debugPrint('回彈距離:$leftAndRightReboundDistance\n-------------------');

} ``` 在左滑進度percent > 0.5 && percent < 1.0裡操作

``` ///在進度末尾的時候完成回彈效果,另一邊的點,先縮後恢復 if(percent >= 0.9 && percent < 0.95){

///因為是點P2,起始X座標為radius*2,所以X軸向左位移,減就等於縮 ///第一步,縮,比例為:0 ~ 0.2 p2.x = p2.x - leftAndRightIndentedDistance; p2L.x = p2L.x - leftAndRightIndentedDistance; p2R.x = p2R.x - leftAndRightIndentedDistance;

}else if( percent >= 0.95){

///第二步,恢復,比例為:0.2 ~ 0 p2.x = p2.x - leftAndRightReboundDistance; p2L.x = p2L.x - leftAndRightReboundDistance; p2R.x = p2R.x - leftAndRightReboundDistance;

} ```

加上回彈後的效果

X軸回彈效果.gif

Y軸回彈效果

如果加上Y軸的回彈,效果會不會更好一點,本質是一樣的,上下兩條控制桿同時往圓心接近然後恢復

///擠壓係數 double extrusion = 0.4;

``` /// 上下壓縮和回彈的效果 /// p1L、p1、p1R、p3L、p3、p3R 上下6個座標 /// radius 要位移的距離(縱軸的縮放小,所以只選擇一個半徑的距離) /// percent 當前頁面滑動的進度 /// extrusion 效果放大的係數 void compressionAndRebound(Point p1L,Point p1,Point p1R,Point p3L,Point p3,Point p3R,double percent,double extrusion){

///根據percent進度變化,壓縮和回彈的區別: ///進度的大小:遞增 = 壓縮 遞減 = 回彈

///頂部y軸變化 ///所有座標都是在原本的位置變化 ///p1原y軸:0 p1L.y = radiuspercentextrusion; p1.y = radiuspercentextrusion; p1R.y = radiuspercentextrusion;

///底部y軸變化 ///p3原y軸:radius2 p3L.y = radius2 - radiuspercentextrusion; p3.y = radius2 - radiuspercentextrusion; p3R.y = radius2 - radiuspercentextrusion; } ```

分別在左滑右滑進度,不論左滑還是右滑,在同進度區間裡都是一樣

percent > 0 && percent <= 0.5 ///上下壓縮的效果 compressionAndRebound(p1L, p1, p1R, p3L, p3, p3R, percent, extrusion);

percent > 0.5 && percent < 1.0

///上下回彈的效果 compressionAndRebound(p1L, p1, p1R, p3L, p3, p3R, (1 - percent), extrusion);

效果

Y軸回彈效果.gif

顏色過度

顏色過度.gif

原版是用HSVColor,但這有中間色彩,感覺和設計圖不是很像,我打算用顏色透明度過度

目前的效果只是近似,在_IndicatorView的build裡建立新的變數,然後把nowColor傳給BaseView

``` ///顏色進度 double colorPercent = 0.0;

///顏色透明度 double colorOpacity = 0.0;

///當前顏色 Color nowColor = colors.first;

colorPercent = nowCurPosition - nowCurPosition.toInt();

///顏色變化在進度70%左右開始 if (colorPercent >= 0 && colorPercent <= 0.7) { colorOpacity = ( 1.0 - colorPercent ); ///不到70%就是之前的分頁顏色 nowColor = colors[nowCurPosition.toInt()].withOpacity(colorOpacity <= 0.3 ?0.5:colorOpacity); }else if (colorPercent > 0.7 && colorPercent <= 1.0) { ///過了70%就是後面的分頁的顏色 nowColor = colors[nowCurPosition.ceil()].withOpacity(colorPercent); } ``` 效果

目前顏色透明漸變.gif

結束語

這次探索可以擴充套件到各種形狀的變化,只要你會了這個技能,你會發現很多好看的動畫都可以做到。

目前還有很多細節沒有模仿到位,比如不同進度上,顏色漸變的速度分頁滑動的回彈指示器應該是兩邊都拉伸等等。歡迎大家一起討論,也可以在下方留言,我看到會及時回覆。

實際用到另外一個效果

帶標題超出螢幕的分頁示意.gif

我發現只有每個標題都是同樣的寬度才可以,當後面加標題而且文字長度都不一的時候,上述寬度計算方法就會無效,並且如果分頁數量很多,超過螢幕,就不能用Row,得用ListView來佈局

這時候間距和如果達到類似騰訊新聞分頁標題那樣的效果還得再細分析一番,待我完成後下一篇再來個教程。

最後放上所有程式碼供大家參考

首先是IndicatorPage

``` import 'package:water_drop_paging/BaseView.dart'; import 'package:flutter/material.dart';

class IndicatorPage extends StatelessWidget { @override Widget build(BuildContext context) {

return Scaffold(
  appBar: AppBar(
    backgroundColor: Color.fromRGBO(20, 26, 36, 1),
    title: Text("指示器"),
  ),
  body: _IndicatorView(),
);

}

}

class _IndicatorView extends StatefulWidget{

@override State createState() { return _IndicatorState(); }

}

class _IndicatorState extends State<_IndicatorView> {

///當前頁碼,小數代表進度 double nowCurPosition = 0.0;

///上一次的page double oldCurPosition = 0.0;

///半徑 double radius = 20.0;

///是否是向右 bool isToRight = true;

///分頁控制器 late PageController pageController;

///分頁色彩 List colors = [ Colors.red, Colors.deepOrange, Colors.amber, Colors.blue, Colors.deepPurpleAccent ];

@override void initState() { super.initState();

///設定係數比例為0.8
pageController = PageController(viewportFraction: 0.8);

pageController.addListener((){
  ///當前page資料
  nowCurPosition = pageController.page!;

  ///比對上一次來判斷左滑還是右滑
  if (nowCurPosition > oldCurPosition) {
    isToRight = true;
    // debugPrint('往左滑');
  } else {
    isToRight = false;
    // debugPrint('往右滑');
  }

  ///比對結束賦值
  oldCurPosition = nowCurPosition;

  setState(() {});

});

}

@override Widget build(BuildContext context) {

///頁數去掉整數部分,一次翻頁的進度,不論左滑還是右滑都得是同一個百分數。用於計算動畫的進度
double percent = 0.0;

///顏色進度
double colorPercent = 0.0;

///顏色透明度
double colorOpacity = 0.0;

///當前顏色
Color nowColor = colors.first;

if (isToRight) {
  /// 2.0354 - 2 正向運動 = 0.0354
  percent = nowCurPosition - nowCurPosition.toInt();
} else {
  ///反向運動,進度由大變小 0.9 -> 0.1 所以 2.9 - 2 = 0.9 ,但實際是 1 - 0.9 = 0.1
  percent =  1 - (nowCurPosition - nowCurPosition.toInt());
}

colorPercent = nowCurPosition - nowCurPosition.toInt();

///獲取當前螢幕的寬度
double deviceWidth = MediaQuery.of(context).size.width;
///位移的距離,相對於起始位置
double offSetX = deviceWidth*0.1+(deviceWidth - radius*2 - deviceWidth*0.2)*nowCurPosition/4;

///顏色變化在進度70%左右開始
if (colorPercent >= 0 && colorPercent <= 0.7) {
  colorOpacity = ( 1.0 - colorPercent );
  ///不到70%就是之前的分頁顏色
  nowColor = colors[nowCurPosition.toInt()].withOpacity(colorOpacity <= 0.3 ?0.5:colorOpacity);
}else if (colorPercent > 0.7 && colorPercent <= 1.0) {
  ///過了70%就是後面的分頁的顏色
  nowColor = colors[nowCurPosition.ceil()].withOpacity(colorPercent);
}

return Container(
  color: Color.fromRGBO(20, 26, 36, 1),///統一背景色
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      Container(
        height: 220,
        margin: EdgeInsets.only(bottom: 16.0),
        child: PageView.builder(
          itemBuilder: (context,index){
            ///新增邊距,顯示效果為長矩形
            return Container(
              margin: EdgeInsets.all(8.0),
              height: 220,
              child: Card(
                elevation: 10,
                color: colors[index],
                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
              ),
            );
          },
          itemCount: 5,
          scrollDirection: Axis.horizontal,
          reverse: false,
          controller: pageController,
          physics: const PageScrollPhysics(parent: BouncingScrollPhysics()),
        ),
      ),
      Stack(
        children: [
          Container(
            padding: EdgeInsets.only(left: deviceWidth*0.1,right: deviceWidth*0.1),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: backgroundWireframe(),
            ),
          ),
          Positioned(
            child: Transform.translate(
              offset: Offset(offSetX, 0),
              child: CustomPaint(
                painter: BaseView(
                  radius: radius,
                  percent: percent,
                  isToRight: isToRight,
                  color: nowColor
                ),
              ),
            ),
          )
        ],
      ),
    ],
  ),
);

}

///新增指示器的背景圓形線框 List backgroundWireframe(){ List widgets = []; while(widgets.length < 5){ widgets.add( Container( width: radius2, height: radius2, decoration: BoxDecoration( border: Border.all(color: Colors.white,width: 1,style: BorderStyle.solid), borderRadius: BorderRadius.all(Radius.circular(20)) ), ), ); } return widgets; }

} ```

最後是BaseView

``` import 'package:flutter/material.dart';

class Point { double x; double y; Point({required this.x,required this.y}); }

class BaseView extends CustomPainter{

final double radius; final double M = 0.551915024494; final double percent; final bool isToRight; final Color color; late Paint curvePaint; late Path curvePath;

BaseView({ required this.radius, required this.percent, required this.isToRight, required this.color, }){ curvePaint = Paint() ..style = PaintingStyle.fill; curvePath = Path(); }

@override void paint(Canvas canvas, Size size) { curvePath.reset(); curvePaint.color = this.color; _canvasBesselPath(curvePath); canvas.drawPath(curvePath, curvePaint); }

void _canvasBesselPath(Path path) {

///控制點的位置,半徑的0.55倍左右,這時候是正圓,所以我們從0.55倍的比例開始
double tangentLineLength = radius*M;

///擠壓係數
double extrusion = 0.4;

///拉伸係數
double stretch = 2;

///回彈係數,回彈係數,乘以4是為了回彈效果明顯一點,數字越大效果越明顯)
double rebound = 4;

///位移距離
double displacementDistance = radius*stretch;

///回彈效果的左右壓縮的距離,因為是從80%開始縮排遞增,所以要percent - 0.8
double leftAndRightIndentedDistance = displacementDistance*(percent - 0.9)*rebound;

///回彈效果的左右恢復的距離,因為是回彈需要遞減,而percent是遞增,所以要1 - percent
double leftAndRightReboundDistance = displacementDistance*(1 - percent)*rebound;

///頂端
Point p1 = Point(x: radius,y: 0);
///右邊
Point p2 = Point(x: radius*2,y: radius);
///底端
Point p3 = Point(x: radius,y: radius*2);
///左邊
Point p4 = Point(x: 0,y: radius);

///頂端左右控制點
Point p1L = Point(x: radius - tangentLineLength,y: 0);
Point p1R = Point(x: radius + tangentLineLength,y: 0);

///右邊左右控制點
Point p2L = Point(x: radius*2,y: radius - tangentLineLength);
Point p2R = Point(x: radius*2,y: radius + tangentLineLength);

///底端左右控制點
Point p3L = Point(x: radius - tangentLineLength,y: radius*2);
Point p3R = Point(x: radius + tangentLineLength,y: radius*2);

///左邊左右控制點
Point p4L = Point(x: 0,y: radius + tangentLineLength);
Point p4R = Point(x: 0,y: radius - tangentLineLength);

///漲就是位移的距離長,縮就是位移的距離短,速率要一致(倍數)
if (isToRight) {///判斷左劃右劃

  ///先漲後縮
  if (percent > 0 && percent <= 0.5) {

    ///座標右移,原本的位置 + 進度✖半徑
    p2.x = radius*2 + displacementDistance*percent;
    p2L.x = radius*2 + displacementDistance*percent;
    p2R.x =radius*2 + displacementDistance*percent;

    ///上下壓縮的效果
    compressionAndRebound(p1L, p1, p1R, p3L, p3, p3R, percent, extrusion);

  }else if (percent > 0.5 && percent < 1.0) {

    ///在進度末尾的時候完成回彈效果,另一邊的點,先縮後恢復
    if(percent >= 0.9 && percent < 0.95){

      ///第一步,縮,比例為:0 ~ 0.2
      ///因為是點P4,起始X座標為0,所以X軸向右位移,加就等於縮
      p4.x = leftAndRightIndentedDistance;
      p4L.x = leftAndRightIndentedDistance;
      p4R.x = leftAndRightIndentedDistance;
      // debugPrint('縮排距離:$leftAndRightIndentedDistance\n');

    }else if( percent >= 0.95){

      ///第二步,恢復,比例為:0.2 ~ 0
      ///恢復其實就是向右位移的距離逐步減少
      ///比例為:0.2 ~ 0,這裡的倍數要和之前縮的倍數一致
      p4.x = leftAndRightReboundDistance;
      p4L.x = leftAndRightReboundDistance;
      p4R.x = leftAndRightReboundDistance;
      // debugPrint('回彈距離:$leftAndRightReboundDistance\n-------------------');

    }

    ///座標恢復,原本的位置 + 半徑✖係數,係數為: 0.5 ~ 0
    p2.x = p2.x + displacementDistance*(1 - percent);
    p2L.x = p2L.x + displacementDistance*(1 - percent);
    p2R.x = p2R.x + displacementDistance*(1 - percent);

    ///上下回彈的效果
    compressionAndRebound(p1L, p1, p1R, p3L, p3, p3R, (1 - percent), extrusion);

  }
} else {

  ///先漲後縮
  if (percent > 0 && percent <= 0.5) {

    ///座標左移,原本的位置 + 進度✖半徑
    p4.x = p4.x - displacementDistance*percent;
    p4L.x = p4L.x - displacementDistance*percent;
    p4R.x = p4R.x - displacementDistance*percent;

    ///不論左劃右劃,重複
    ///上下壓縮的效果
    compressionAndRebound(p1L, p1, p1R, p3L, p3, p3R, percent, extrusion);

  }else if (percent > 0.5 && percent < 1.0) {

    ///在進度末尾的時候完成回彈效果,另一邊的點,先縮後恢復
    if(percent >= 0.9 && percent < 0.95){

      ///因為是點P2,起始X座標為radius*2,所以X軸向左位移,減就等於縮
      ///第一步,縮,比例為:0 ~ 0.2
      p2.x = p2.x - leftAndRightIndentedDistance;
      p2L.x = p2L.x - leftAndRightIndentedDistance;
      p2R.x = p2R.x - leftAndRightIndentedDistance;

    }else if( percent >= 0.95){

      ///第二步,恢復,比例為:0.2 ~ 0
      p2.x = p2.x - leftAndRightReboundDistance;
      p2L.x = p2L.x - leftAndRightReboundDistance;
      p2R.x = p2R.x - leftAndRightReboundDistance;

    }

    ///座標恢復,原本的位置 + 半徑✖係數,係數為: 0.5 ~ 0
    p4.x = p4.x - displacementDistance*(1 - percent);
    p4L.x = p4L.x - displacementDistance*(1 - percent);
    p4R.x = p4R.x - displacementDistance*(1 - percent);

    ///重複,和右滑一樣
    compressionAndRebound(p1L, p1, p1R, p3L, p3, p3R,(1 - percent), extrusion);

  }
}

///所有點都確定位置後,開始繪製連線
///先從原點移動到第一個點P1
path.moveTo(p1.x, p1.y);

///順時針一起連線點,p1-p1R-p2L-p2
path.cubicTo(
    p1R.x, p1R.y,
    p2L.x, p2L.y,
    p2.x, p2.y
);

///p2-p2R-p3R-p3
path.cubicTo(
    p2R.x, p2R.y,
    p3R.x, p3R.y,
    p3.x, p3.y
);

///p3-p3L-p4L-p4
path.cubicTo(
    p3L.x, p3L.y,
    p4L.x, p4L.y,
    p4.x, p4.y
);

///p4-p4R-p1L-p1
path.cubicTo(
    p4R.x, p4R.y,
    p1L.x, p1L.y,
    p1.x, p1.y
);

}

@override bool shouldRepaint(CustomPainter oldDelegate) => true;

/// 上下壓縮和回彈的效果 /// p1L、p1、p1R、p3L、p3、p3R 上下6個座標 /// radius 要位移的距離(縱軸的縮放小,所以只選擇一個半徑的距離) /// percent 當前頁面滑動的進度 /// extrusion 效果放大的係數 void compressionAndRebound(Point p1L,Point p1,Point p1R,Point p3L,Point p3,Point p3R,double percent,double extrusion){

///根據percent進度變化,壓縮和回彈的區別:
///進度的大小:遞增 = 壓縮    遞減 = 回彈

///頂部y軸變化
///所有座標都是在原本的位置變化
///p1原y軸:0
p1L.y = radius*percent*extrusion;
p1.y = radius*percent*extrusion;
p1R.y = radius*percent*extrusion;

///底部y軸變化
///p3原y軸:radius*2
p3L.y = radius*2 - radius*percent*extrusion;
p3.y = radius*2 - radius*percent*extrusion;
p3R.y = radius*2 - radius*percent*extrusion;

}

} ```