Flutter之初识贝塞尔曲线 - 实现炫酷的路由动画

7,124 阅读8分钟

前言

参考文献:

在之前的有一篇文章中实现了仿酷安的主题更改,其实也是当时群里人随口一说:“这个看起来好炫酷,能不能用Flutter来实现”,在我眼里Flutter做东西,绝大部分都是用心都可以实现。

Flutter炫酷的波纹路由动画

于是在发布上一篇文章后,有人给我评论了一个dribbble上的设计,长这个样子:

第一眼望过去,的确挺炫酷的,目测就比较有难度,反正Flutter有热重载,首先就开始用用最基本的组件一点一点试。

是否可以用两个简单的圆的动画来实现?

我最初想到的也是这个思路,但最后我放弃了(也许你可以成功哈)。

原因:

  • (1) 观察上面的动画,在动画到一半也就是中间有一条直线的时候,不难想象,一个圆的可以大到在手机屏幕内呈直线的话,那么这个圆的dp应该得上 100,000(测试中的数据),而且永远都不能达到绝对的直线。

  • (2)即使在视觉上给人了直线的感觉,抛开性能的开销,从动画的层面分析,圆的半径就是从一个按钮的大小(假设50dp)扩大到 100,000 ,让人感知的数据范围也仅仅在 50dp~1000dp ,而这部分的数据仅仅占用整个动画的 1% ,所以这个动画的时长是极其难控制的。

本篇文章实现方案涉及以下数学知识:

  • 余弦定理
  • 贝塞尔曲线
  • 空间坐标点的旋转

其实整个动画的实现是不会花太多时间的,由于我是初次学习贝塞尔曲线的使用,还有复习一些高中的知识,还花了些时间。

一、认识贝塞尔曲线

贝塞尔曲线根据控制点的数量分为:

  • 一阶贝塞尔曲线(2 个控制点)
  • 二阶贝塞尔曲线(3 个控制点)
  • 三阶贝塞尔曲线(4 个控制点)
  • n阶贝塞尔曲线(n+1个控制点)

在Flutter的Path类中,往往二阶贝塞尔只需要 2 个点作为参数,三阶贝塞尔只需要 3 个点作为参数,这是一个需要注意的地方,Path默认当前的点为初始点。

1、一阶贝塞尔

每一时刻曲线的点对应公式:

2、二阶贝塞尔

每一时刻曲线的点对应公式:

3、三阶贝塞尔

每一时刻曲线的点对应公式:

这部分的图取自文章顶部第一篇参考文章,公式来自百度百科,用动图能更好的提现出它的轨迹。

二、使用贝塞尔曲线画圆

由于这部分的具体原理研究在本文顶部的第二篇参考文献中写得非常详细,我们主要关心整个动画的实现。

1、画出近似 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个点画出圆弧

2、拼接成整个圆

代码

 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 个就足够了,因为初始点为当前点在的位置。

三、动画的实现

1、动画的分割

我将整个动画分成四个部分

右圆动画:

  • 1.由一个小圆逐渐变大,高度最值可以刚好撑满屏幕。
  • 2.将 1/4 的圆弧逐渐拉直,模拟圆继续扩大的效果。

左圆动画:

  • 1.将直线逐渐向 1/4 的圆弧过渡。
  • 2.由一个大圆逐渐变小。

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 即为按钮中心坐标与屏幕顶端坐标。

第一部分动画最后效果:

3、实现第二部分动画

这部分动画是耗时最久的,为了方便动画设计,我先将第一部分动画末的圆半径减小。

我们通过旋转四阶贝塞尔的末两个控制点来模拟圆弧的变化。

图示:

我们只需要旋转一组控制点的后两个或者前两个,所以涉及到的数学知识就是,我们需要准确计算出一个点相对另一个点旋转指定角度后的位置。

代码实现:

  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 个点是共用的。

解决方案

在因为共用控制点导致路径不符合预期的点添加一组二阶贝塞尔,用来连接上一组的终点与下一组的起点。

我们填上颜色再看看:

使用实际计算的半径:

整个动画的难点差不多就完了。

4、实现第三、四部分动画

这两部分动画就不细讲了,就是创建一个新的圆,与之前圆的动画数据全部相反,圆心坐标不同。

初步实现

加上一些布尔值,当第三部分动画开始的时候,在左侧创建一整个矩形方块,然后利用路径相减,同时取消掉红色的辅助按钮。

再看效果:

观察原动画,路由前、路由页面的组件都有一个位移的动画,在左圆即将到最小的时候也有一个位移动画。

我们将所有的处理完成。

最终效果

安卓设备上:

ffmpeg 在转换的时候 gif 变慢了不知道为啥。另外路由前页面的位移动画,与路由后的页面位移动画需要另外加了 😑 。

还有群友提到的细节,我没能优化,遇见坑了,当前的动画的最后一部分是以不太优雅的方式实现的 😳 。

四、组件封装

让这个路由的使用变得更加容易。

我优先考虑的是封装成 PageRouteBuilder ,最后由于涉及过多的动画,最终将它封装成了组件的形式。

你只需要在任何位置使用这个按钮:

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

或者你想另外封装,在 lib 下 cool_button.dart 中的 CoolAnimation 有整个动画的实现,包括性能优化与细节处理,都还需要自己改下代码。

demo 地址:

MYS_Flutter

五、结语

  • 代码写得比较赶,很多地方不规范,我在将这个路由引入自己项目后会改进。
  • 上一篇“ 炫酷的波纹路由动画 ”是今年 2 月发布的,与现在对比了写文章的格式与排版,也算看到了自己这五个月在这方面的进步。
  • “ MYS_Flutter ”初次创建是在两年前了,里面的其他 demo 的最后一次更新也是两年前😶 。
  • 自己手上没有各种各样的项目,所以在学 Flutter 的时候不需要整天组各种各样的轮子,对我而言,我永远喜欢尝试新的东西,这也是我尝试了 Flutter 的原因。

happy coding !