原因

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

规划图地址

我计划给分页加一个美观的动画作用,想起以前有看到过水滴款式的分页指示器,但网上的样例不是许多。

某天找到掘金的这篇文章–# Flutter 自定义组件之贝塞尔曲线制作波涛球 感觉作用不错,但苦于文章讲的不是很细,所以没能很快的了解完结的方法,于是在通过几天的探索后完结的动效的完结,代码在mochixuan源码的基础上修正的,但基本上核算部分满是我自己的思路,并附上了详细的注释

假如你一开端也对规划图的作用不知道怎么完结,无妨跟着这篇文章一同走下去,相信看完你就能知道假如做出一模相同的动画。(本文用许多的GIF动画让你轻松了解)

本文最终发现的一些问题,鄙人已经鄙人一篇文章里解决 Flutter 水滴分页指示器进阶 – 掘金 ()

先看完结作用

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

剖析

首先肯定是先剖析一下规划图里的一些细节作用首要分为上下两部分

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

分页

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

分页指示器

分页部分不必多少,便是普通的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()),
          ),
        ),
      ],
    ),
  );
}

到现在,咱们已经完结了分页部分,作用如下:

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

指示器部分

布景线框

然后咱们增加指示器部分的布景线框,而且去掉分页部分的布景色

///增加指示器的布景圆形线框
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;
}

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

制作第一个水滴圆

所需常识

制作圆所需求的贝塞尔曲线常识,能够参阅这篇文章 怎么了解并应用贝塞尔曲线

所用软件

然后有请咱们的制作软件GeoGebar隆重登场(免费)

附上官方链接GeoGebra – 风行国际, 过亿师生沉迷使用的免费数学软件

曲线画圆思路

首要思路也是把圆以圆心的坐标系分红4段曲线

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

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

Flutter 贝塞尔曲线动画的实现思路(水滴分页指示器)
图中一切标记为赤色和绿色的点都是和进展参数挂钩,8个控制点和M系数挂钩,也便是说8个控制杆(例如线段f=P1-P1R)的长度和M的值是一至的

当M = 1 的时分,咱们敞开Z,A1,B1,C1这四个点的轨迹,而且敞开进展动画时分,咱们能看到一个由4个3阶贝塞尔曲线构成的圆角矩形

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

M = 0.552的时分

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

通过画图或者公式咱们能够得知M的系数在0.552…左右的时分,已挨近1/4个圆弧

所以咱们得到了转化后的8个控制点坐标,当然在手机上的坐标系是这样的(赤色坐标系)

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

在咱们知道一切的点的时分就能够画水滴的初始状态(圆)

接下来,咱们定义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是间隔屏幕两头的间隔,运行一下,咱们能够看到

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

跟着分页移动无形变

这儿需求算出位移的间隔

创立新的变量

///当时页码,小数代表进展
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

咱们能够看出整数位是页数,小数位是当时页面的滑动进展

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

///位移的间隔,相对于开端方位
double offSetX = deviceWidth*0.1+(deviceWidth - radius*2 - deviceWidth*0.2)*nowCurPosition/4;

把得到的offSetX代入Transform的offset后,咱们得到了没有形变的指示器

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

跟着分页移动有形变

当咱们只全体移动P2L-P2-P2R这条线段,我设置了2个点,一个2倍半径,一个3倍半径,看看作用

Flutter 贝塞尔曲线动画的实现思路(水滴分页指示器)
这能够作为指示器右移的时分的形变,同样左移便是P4L-P4-P4R的左移

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

咱们只需求知道当时是左移仍是右移位移的进展,就能够全体移动左右线段来体现形变拉伸的作用

那么在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);
  }
}

看一下作用

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

扩大拉伸作用

咱们能够简略的加大拉伸(扩大系数),之前代码里,咱们仅仅按照增加一倍半径。

///拉伸系数
double stretch = 2;
///位移间隔
double displacementDistance = radius*stretch;

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

现在还没编辑完,动图太多了。预计明日能搞完。

X轴回弹作用

咱们仔细看作用图里,挨近拉伸完毕后反方向会有回弹的作用

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

便是反方向的控制杆往圆心先挨近后康复的作用

在设置坐标之前设置一些变量,这儿的回弹都是进展挨近完毕的时分,所以我挑选了 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;
}

加上回弹后的作用

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

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);

作用

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

色彩过度

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

原版是用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);
}

作用

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

完毕语

这次探索能够扩展到各种形状的改变,只需你会了这个技能,你会发现许多美观的动画都能够做到。

现在还有许多细节没有仿照到位,比如不同进展上,色彩突变的速度分页滑动的回弹指示器应该是两头都拉伸等等。欢迎我们一同讨论,也能够鄙人方留言,我看到会及时回复。

实践用到别的一个作用

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

我发现只有每个标题都是同样的宽度才能够,当后边加标题而且文字长度都不一的时分,上述宽度核算方法就会无效,而且假如分页数量许多,超过屏幕,就不能用Row,得用ListView来布局

这时分间隔和假如达到类似腾讯新闻分页标题那样的作用还得再细剖析一番,待我完结后下一篇再来个教程。

教程来了 Flutter 水滴分页指示器进阶 – 掘金 ()

最终放上一切代码供我们参阅

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