Flutter 贝塞尔曲线动画的实现思路(水滴分页指示器)

3,276 阅读8分钟

起因

69b7d63agw1evxh2kw1bcg20m80go1l2.gif

设计图地址

我打算给分页加一个好看的动画效果,想起以前有看到过水滴样式的分页指示器,但网上的样例不是很多。

某天找到掘金的这篇文章--# Flutter 自定义组件之贝塞尔曲线绘制波浪球 感觉效果不错,但苦于文章讲的不是很细,所以没能很快的理解实现的方法,于是在经过几天的摸索后完成的动效的实现,代码在mochixuan源码的基础上修改的,但基本上计算部分全是我自己的思路,并附上了详细的注释

如果你一开始也对设计图的效果不知道怎么实现,不妨跟着这篇文章一起走下去,相信看完你就能知道如果做出一模一样的动画。(本文用大量的GIF动画让你轻松理解)

本文最后发现的一些问题,在下已经在下一篇文章里解决 Flutter 水滴分页指示器进阶 - 掘金 (juejin.cn)

先看完成效果

目前颜色透明渐变.gif

分析

首先肯定是先分析一下设计图里的一些细节效果主要分为上下两部分

分页.gif

分页

分页指示器.gif

分页指示器

分页部分不用多少,就是普通的PageView,这里的难点是如何精确判断:

  • 当前是哪个页面
  • 当前是左滑还是右滑
  • 当前滑动的进度

分页指示器是我们动画的重点,要求:

  • 水滴对应当前分页
  • 页数递增,水滴被向右拉伸
  • 页数递减,水滴被向左拉伸
  • 水滴有回弹填充的效果
  • 颜色对应分页
  • 颜色变化存在透明度的变化
  • 点击分页指示器的水滴可以直接跳转到对应的分页

实现

分页部分

我们先实现分页部分,这部分直接贴源码

变量

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

///分页色彩
List<Color> 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: <Widget>[
        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: <Widget>[
        Container(...),
        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(deviceWidth*0.1, 0),
                child: CustomPaint(
                  painter: BaseView(
                      radius: radius,
                  ),
                ),
              ),
            )
          ],
        ),
      ],
    ),
  );
}

这里deviceWidth*0.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 = 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;
}

分别在左滑右滑进度,不论左滑还是右滑,在同进度区间里都是一样

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来布局

这时候间距和如果达到类似腾讯新闻分页标题那样的效果还得再细分析一番,待我完成后下一篇再来个教程。

教程来了 Flutter 水滴分页指示器进阶 - 掘金 (juejin.cn)

最后放上所有代码供大家参考

首先是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<StatefulWidget> 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<Color> 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<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;
  }

}

最后是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;
  }


}