【Flutter高级玩法】 贝塞尔曲线的表象认知

零、前言

本文所有代码: 【github:https://github.com/toly1994328/flutter_play_bezier】

先看看本文要干嘛:

----

在玩贝塞尔之前先做点准备活动热热身。打个网格对学习贝塞尔曲线是很有帮助的。如下是以中心为原点的坐标系,x向右y向下

0.1 : 主程序
void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home:Paper());
  }
}

0.2 : 自定义Paper组件显示画布

为了绘制的纯粹和雅观,这里把状态量去掉,并且手机横向。

/// create by 张风捷特烈 on 2020-03-27
/// contact me by email 1981462002@qq.com
/// 说明: 纸

class Paper extends StatefulWidget {
  @override
  _PaperState createState() => _PaperState();
}

class _PaperState extends State<Paper> {
  @override
  void initState() {
    //横屏
    SystemChrome.setPreferredOrientations(
        [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
    //全屏显示
    SystemChrome.setEnabledSystemUIOverlays([]);
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return  CustomPaint(
        painter: BezierPainter(),
    );
  }
}

0.3 : 绘制网格
注意: 这里永久的将画布原点移到画布的中心点,之后所以的绘制都将以中心为(0,0)点。

/// create by 张风捷特烈 on 2020-03-27
/// contact me by email 1981462002@qq.com
/// 说明: 贝塞尔曲线测试画布

class BezierPainter extends CustomPainter {
  Paint _gridPaint;
  Path _gridPath;

  BezierPainter() {
    _gridPaint = Paint()..style=PaintingStyle.stroke;
    _gridPath = Path();
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawColor(Colors.white, BlendMode.color);
    canvas.translate(size.width/2, size.height/2);
    _drawGrid(canvas,size);//绘制格线
    _drawAxis(canvas, size);//绘制轴线
  }

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

  void _drawGrid(Canvas canvas, Size size) {
    _gridPaint
    ..color = Colors.grey
    ..strokeWidth = 0.5;
    _gridPath = _buildGridPath(_gridPath, size);
    canvas.drawPath(_buildGridPath(_gridPath, size), _gridPaint);

    canvas.save();
    canvas.scale(1, -1); //沿x轴镜像
    canvas.drawPath(_gridPath, _gridPaint);
    canvas.restore();

    canvas.save();
    canvas.scale(-1, 1); //沿y轴镜像
    canvas.drawPath(_gridPath, _gridPaint);
    canvas.restore();

    canvas.save();
    canvas.scale(-1, -1); //沿原点镜像
    canvas.drawPath(_gridPath, _gridPaint);
    canvas.restore();

  }

  void _drawAxis(Canvas canvas, Size size) {
    canvas.drawPoints(PointMode.lines, [
      Offset(-size.width/2, 0) , Offset(size.width/2, 0),
      Offset( 0,-size.height/2) , Offset( 0,size.height/2),
      Offset( 0,size.height/2) , Offset( 0-7.0,size.height/2-10),
      Offset( 0,size.height/2) , Offset( 0+7.0,size.height/2-10),
      Offset(size.width/2, 0) , Offset(size.width/2-10, 7),
      Offset(size.width/2, 0) , Offset(size.width/2-10, -7),
    ], _gridPaint..color=Colors.blue..strokeWidth=1.5);
  }

  Path _buildGridPath(Path path, Size size,{step = 20.0}) {
    for (int i = 0; i < size.height / 2 / step; i++) {
      path.moveTo(0, step * i);
      path.relativeLineTo(size.width / 2, 0);
    }
    for (int i = 0; i < size.width / 2 / step; i++) {
      path.moveTo( step * i,0);
      path.relativeLineTo(0,size.height / 2, );
    }
    return path;
  }
}

0.4、人生至美莫初见

先不看哪些花里胡哨的贝塞尔曲线的动画。让我们从实践中一点点去摸索。如此美丽的初见,为何要这么复杂?当你渐渐去认识她,了解她,熟悉她,便会明白:哦,原来如此如此,这般这般...

  • 看到贝塞尔三个字,也不用觉得压力太大,满打满算也就两个函数而已。
---->[二次贝塞尔曲线]----
void quadraticBezierTo(double x1, double y1, double x2, double y2)
void relativeQuadraticBezierTo(double x1, double y1, double x2, double y2)

---->[三次贝塞尔曲线]----
void cubicTo(double x1, double y1, double x2, double y2, double x3, double y3)
void relativeCubicTo(double x1, double y1, double x2, double y2, double x3, double y3)

一、二次贝塞尔曲线

二次贝塞尔曲线需要传入四个double类型的值。

1. 先画一笔看看

首先新准备个画笔和路径,在构造函数里初始化。准备两个测试点p1,p2,
然后轻轻的用quadraticBezierTo描一笔,就出来一个曲线。

class BezierPainter extends CustomPainter {
  // 英雄所见...
  Paint _mainPaint;
  Path _mainPath;

  BezierPainter() {
    // 英雄所见...

    _mainPaint = Paint()..color=Colors.orange..style=PaintingStyle.stroke..strokeWidth=2;
    _mainPath = Path();
  }
  Offset p0 =Offset(0, 0);
  Offset p1 =Offset(100, 100);
  Offset p2 =Offset( 120, -60);
  
    @override
  void paint(Canvas canvas, Size size) {
    // 英雄所见...
    _mainPath.moveTo(p0.dx, p0.dy);
    _mainPath.quadraticBezierTo(p1.dx, p1.dy, p2.dx, p2.dy);
    canvas.drawPath(_mainPath, _mainPaint);
  }

2.为什么曲线会是这样的?

为了更好的理解贝塞尔曲线,现在我们需要绘制辅助帮我们理解。现在想将与贝塞尔曲线有关系的三个点画出来。同样,我不想弄脏画笔,所以新拿一个_helpPaint。在_drawHelp方法里进行绘制辅助线。

class BezierPainter extends CustomPainter {
  // 英雄所见...
  Paint _helpPaint;

  BezierPainter() {
      // 英雄所见...
    _helpPaint = Paint()
    ..color=Colors.purple
    ..style=PaintingStyle.stroke
    ..strokeCap=StrokeCap.round;
  }
 
 void _drawHelp(Canvas canvas) {
  canvas.drawPoints(PointMode.points,[p0, p1, p1,p2], _helpPaint..strokeWidth=8);
}
  • 看到上图,你是不是发现的什么?如果还比较懵,再画一道辅助线

void _drawHelp(Canvas canvas) {
  canvas.drawPoints(PointMode.lines,[p0, p1, p1,p2], _helpPaint..strokeWidth=1);
  canvas.drawPoints(PointMode.points,[p0, p1, p1,p2], _helpPaint..strokeWidth=8);
}

3. 来玩一下这个曲线

这不就是三个点嘛,要能拖拖看就好了。没问题,应你所求

现在有两个要点: 【1】 如何获取触点  【2】如何通过一个触点控制三个点位

  • 简单讲解

由于点位需要变化,BezierPainter只承担绘制的责任,这里在组件中定义点位信息_pos选中索引_selectIndex ,通过构造函数传入BezierPainter。为了方便大家玩耍,我单独写个文件play_bezier2.dart里面有个PlayBezier2Page组件。

---->[_PaperState]----
class PlayBezier2Page extends StatefulWidget {
  @override
  _PlayBezier2PageState createState() => _PlayBezier2PageState();
}

class _PlayBezier2PageState extends State<PlayBezier2Page> {
  List<Offset> _pos = <Offset>[];
  int _selectPos;

  @override
  void initState() {
    //横屏
    SystemChrome.setPreferredOrientations(
        [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
    //全屏显示
    SystemChrome.setEnabledSystemUIOverlays([]);
    _initPoints();//初始化点
    super.initState();
  }

  • 获取触点信息
    通过GestureDetector组件可以获取触点信息,然后传给画布即可。
    这里的思路很清晰: 在点击时需要判断点击了哪个点,抬起时取消选中点,移动时变化选中点。
@override
Widget build(BuildContext context) {
  return GestureDetector(
    onPanDown: (detail){
     // Todo
    },
    onPanEnd: (detail){
    // Todo
    },
    onPanUpdate: (detail) {
        // Todo
    },
    child: CustomPaint(
      painter: BezierPainter(pos: _pos,selectPos:selectPos),
    ),
  );
}

  • 一个触点控制三个点位

这就有点技术含量了。需要进行点域的判断来确定当前点击的是哪个点。
比如在半径为6的区域内算作命中,就需要在点击时判断是否命中某个点。具体逻辑为:

///判断出是否在某点的半径为r圆范围内
bool judgeCircleArea(Offset src, Offset dst, double r) =>
    (src - dst).distance <= r;
void judgeSelect(Offset src, {double x = 0, double y = 0}) {
  var p = src.translate(-x, -y);
  for (int i = 0; i < _pos.length; i++) {
    if (judgeCircleArea(p, _pos[i], 15)) {
      selectPos = i;
    }
  }
}
void judgeZone(Offset src, {double x = 0, double y = 0}) {
  for (int i = 0; i < _pos.length; i++) {
    if (judgeCircleArea(src, _pos[i], 15)) {
      selectPos = i;
      _pos[i] = src;
    }
  }
}

前三个点需要用户点击,然后画出一段二贝曲线,之后再点击不会添加点,而是判断是否触点在期望的圆域内。这样数据的处理就完成了。根基【捷特第二定理】一切的界面交互和动态视觉效果都是连续时间点状态量的变化和刷新的结合。现在所有的状态量和刷新都已经实现,剩下的就是将这些量显示在界面上。

@override
Widget build(BuildContext context) {
  return GestureDetector(
    onPanDown: (detail) {
      if (_pos.length < 3) {
        _pos.add(detail.localPosition);
      }
      setState(() => judgeSelect(detail.localPosition));
    },
    onPanEnd: (detail) {
      setState(() => selectPos = null);
    },
    onPanUpdate: (detail) {
      setState(() => judgeZone(detail.localPosition));
    },
    child: CustomPaint(
      painter: BezierPainter(pos: _pos, selectPos: selectPos),
    ),
  );
}

  • 绘制

网格和辅助的和上面逻辑基本一致,详见源码,这里就不贴了。当点数小于三个时,仅绘制触点,否则绘制曲线和辅助线。

有一点需要注意: 我们的点位是相对于屏幕左上角的,需要平移到画布中心
class BezierPainter extends CustomPainter {

  Paint _mainPaint;
  Path _mainPath;
  int selectPos;

  List<Offset> pos;

  BezierPainter({this.pos, this.selectPos}) {
    _mainPaint = Paint()
      ..color = Colors.orange
      ..style = PaintingStyle.stroke
      ..strokeWidth = 2;
    _mainPath = Path();
  }

  @override
  void paint(Canvas canvas, Size size) {
    pos = pos.map((e)=>e.translate(-size.width / 2, -size.height / 2)).toList();
    canvas.drawColor(Colors.white, BlendMode.color);
    canvas.translate(size.width / 2, size.height / 2);
    _drawGrid(canvas, size); //绘制格线
    _drawAxis(canvas, size); //绘制轴线

    if(pos.length<3){
      canvas.drawPoints(PointMode.points, pos, _helpPaint..strokeWidth = 8);
    }else{
      _mainPath.moveTo(pos[0].dx, pos[0].dy);
      _mainPath.quadraticBezierTo(pos[1].dx, pos[1].dy, pos[2].dx, pos[2].dy);
      canvas.drawPath(_mainPath, _mainPaint);
      _drawHelp(canvas);
      _drawSelectPos(canvas);
    }
  }

  // 英雄所见...
  void _drawSelectPos(Canvas canvas) {
    if (selectPos == null) return;
    canvas.drawCircle(
        pos[selectPos],
        10,
        _helpPaint
          ..color = Colors.green
          ..strokeWidth = 2);
  }
}

通过前面的介绍,一段二次的贝塞尔曲线有三个点决定,起点控制点终点
关于起点,默认是(0,0),你也在绘制之前moveTo设置起点,当绘制连续的贝塞尔曲线,下一段曲线的起点就是上一段的终点。所以二次贝塞尔曲线至关重要的是两个点: 也就是入参中的控制点和终点


二、三次贝塞尔曲线

前面的二次贝塞尔实现了,那现在来看三次的cubicTo。需要六个参数,也就是三个点。
我们可以使用之前的代码,很快捷的生成如下效果。源代码在play_bezier3.dart


1.实现三贝单线操作

前面点集在_pos中维护,现在需要四个点,so easy

  • 点击时将限制数改为4个
---->[_PlayBezier3PageState]----
onPanDown: (detail) {
  if (_pos.length < 4) {
    _pos.add(detail.localPosition);
  }
  setState(() => judgeSelect(detail.localPosition));
},

  • 绘制将限制数改为4个
if(pos.length<4){
  canvas.drawPoints(PointMode.points, pos, _helpPaint..strokeWidth = 8);
}else{
  _mainPath.moveTo(pos[0].dx, pos[0].dy);
  _mainPath.cubicTo(pos[1].dx, pos[1].dy, pos[2].dx, pos[2].dy, pos[3].dx, pos[3].dy);
  canvas.drawPath(_mainPath, _mainPaint);
  _drawHelp(canvas);
  _drawSelectPos(canvas);
}

That is all ,这就是分工明确的好处,变化时只变需变化待变化的,整体的流程和思路是恒定的。


2.三贝中的拟圆

三贝很厉害,可以说无所不能。只有你想不到,没有她做不到
Ps中的钢笔路径就是多段的三贝曲线。所以还是很有玩头的。

--

  • 绘制拟圆

下面的图看着像个圆,但其实是四段三贝拟合而成的。目前我们的代码中最在意的就是点位数据。所以关键就是寻找点。本小节源码在:circle_bezier.dart

  • 第一段-左下

这里直接给出点,至于0.551915024494是什么,后面有机会会带你一起推导。有兴趣的话,你也可以自己查一查资料。和之前一样,核心的绘制就是那么一句。

---->[CircleBezierPage]----
class CircleBezierPage extends StatefulWidget {
  @override
  _CircleBezierPageState createState() => _CircleBezierPageState();
}

class _CircleBezierPageState extends State<CircleBezierPage> {
  List<Offset> _pos = <Offset>[];
  int selectPos;

  //单位圆(即半径为1)控制线长
  final rate = 0.551915024494;
  double _radius=150;
  @override
  void initState() {
    //横屏
    SystemChrome.setPreferredOrientations(
        [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
    //全屏显示
    SystemChrome.setEnabledSystemUIOverlays([]);
    _initPoints();
    super.initState();
  }

  void _initPoints() {
    _pos = List<Offset>();
    //第一段线
    _pos.add(Offset(0,rate)*_radius);
    _pos.add(Offset(1 - rate, 1)*_radius);
    _pos.add(Offset(1, 1)*_radius);
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
        painter: BezierPainter(pos: _pos, selectPos: selectPos),
        ),
    );
  }
 
---->[BezierPainter#paint]----
_mainPath.moveTo(0, 0);
for (int i = 0; i < pos.length / 3; i++) {
  _mainPath.cubicTo(
       pos[3*i+0].dx,  pos[3*i+0].dy,
       pos[3*i+1].dx, pos[3*i+1].dy,
       pos[3*i+2].dx,  pos[3*i+2].dy);
}

  • 其他三段

初始点时,将这12点放入列表。然后将赋值的点线绘制出来。

---->[CircleBezierPage#_initPoints]----
void _initPoints() {
  _pos = List<Offset>();
  //第一段线
  _pos.add(Offset(0,rate)*_radius);
  _pos.add(Offset(1 - rate, 1)*_radius);
  _pos.add(Offset(1, 1)*_radius);
  //第二段线
  _pos.add(Offset(1 + rate, 1)*_radius);
  _pos.add(Offset(2, rate)*_radius);
  _pos.add(Offset(2, 0)*_radius);
  //第三段线
  _pos.add(Offset(2, -rate)*_radius);
  _pos.add(Offset(1 + rate, -1)*_radius);
  _pos.add(Offset(1, -1)*_radius);
  //第四段线
  _pos.add(Offset(1 - rate, -1)*_radius);
  _pos.add(Offset(0, -rate)*_radius);
  _pos.add(Offset(0, 0));
}

---->[BezierPainter#_drawHelp]----
void _drawHelp(Canvas canvas) {
  _helpPaint..strokeWidth = 1;
  canvas.drawLine(pos[0], pos[11],_helpPaint);
  canvas.drawLine(pos[1], pos[2],_helpPaint);
  canvas.drawLine(pos[2], pos[3],_helpPaint);
  canvas.drawLine(pos[4], pos[5],_helpPaint);
  canvas.drawLine(pos[5], pos[6],_helpPaint);
  canvas.drawLine(pos[7], pos[8],_helpPaint);
  canvas.drawLine(pos[8], pos[9],_helpPaint);
  canvas.drawLine(pos[10], pos[11],_helpPaint);
  canvas.drawLine(pos[11], pos[0],_helpPaint);
  canvas.drawPoints(PointMode.points, pos, _helpPaint..strokeWidth = 8);
}

3.三贝中的拟圆的操作

看这控制柄,满满的拖动欲望,来实现一下吧
有了之前的铺垫,下面的代码应该很容易接受吧。

@override
Widget build(BuildContext context) {
  var x = MediaQuery.of(context).size.width/2;
  var y = MediaQuery.of(context).size.height/2;
  return GestureDetector(
    onPanDown: (detail) {
      setState(() => judgeSelect(detail.localPosition,x: x,y: y));
    },
    onPanEnd: (detail) {
      setState(() => selectPos = null);
    },
    onPanUpdate: (detail) {
      setState(() => judgeZone(detail.localPosition,x: x,y: y));
    },
    child: CustomPaint(
      painter: BezierPainter(pos: _pos, selectPos: selectPos),
    ),
  );
}
///判断出是否在某点的半径为r圆范围内
bool judgeCircleArea(Offset src, Offset dst, double r) =>
    (src - dst).distance <= r;
void judgeSelect(Offset src, {double x = 0, double y = 0}) {
  print(src);
  var p = src.translate(-x, -y);
  print(p);
  for (int i = 0; i < _pos.length; i++) {
    if (judgeCircleArea(p, _pos[i], 15)) {
      selectPos = i;
    }
  }
}
void judgeZone(Offset src, {double x = 0, double y = 0}) {
  var p = src.translate(-x, -y);
  for (int i = 0; i < _pos.length; i++) {
    if (judgeCircleArea(p, _pos[i], 15)) {
      selectPos = i;
      _pos[i] = p;
    }
  }
}

三、贝塞尔曲线与路径操作

也许你觉得贝塞尔曲线也就那样。那么你忽略了一个很重要的东西。
贝塞尔曲线是一条路径。路径是个什么东西,之前写了一篇关于路径使用的冰山一角
【Flutter高级玩法-shape】Path在手,天下我有

现在再准备一条路径,看看路径间的如何操作

class BezierPainter extends CustomPainter {

  Path _clipPath;
  //英雄所见...

  BezierPainter({this.pos, this.selectPos}) {
    _clipPath=Path();
  //英雄所见...
 
 @override
void paint(Canvas canvas, Size size) {
   //英雄所见...
  _clipPath.addOval(Rect.fromCenter(center: Offset(0, 0),width: 100,height: 100));
  canvas.drawPath(_clipPath, _mainPaint);
//英雄所见...
}

1.路径的相减: PathOperation.difference

  @override
  void paint(Canvas canvas, Size size) {
    //英雄所见...
    var drawPath = Path.combine(PathOperation.difference, _mainPath, _clipPath);
    canvas.drawPath(drawPath, _mainPaint);

2.路径的相加: PathOperation.union

  @override
  void paint(Canvas canvas, Size size) {
    //英雄所见...
    var drawPath = Path.combine(PathOperation.union, _mainPath, _clipPath);
    canvas.drawPath(drawPath, _mainPaint);

3.路径的反减: PathOperation.reverseDifference

  @override
  void paint(Canvas canvas, Size size) {
    //英雄所见...
    var drawPath = Path.combine(PathOperation.reverseDifference, _mainPath, _clipPath);
    canvas.drawPath(drawPath, _mainPaint);

4.路径的交集: PathOperation.intersect

  @override
  void paint(Canvas canvas, Size size) {
    //英雄所见...
    var drawPath = Path.combine(PathOperation.intersect, _mainPath, _clipPath);
    canvas.drawPath(drawPath, _mainPaint);

5.路径的反交集: PathOperation.xor

当然路径并非是线条,也可以进行填色。

  @override
  void paint(Canvas canvas, Size size) {
    //英雄所见...
    var drawPath = Path.combine(PathOperation.xor, _mainPath, _clipPath);
    canvas.drawPath(drawPath, _mainPaint..style=PaintingStyle.fill);

OK,本篇到这里就告一段落,下一篇会找几个实际的用途,来看看贝塞尔曲线的妙用。 敬请期待。最后,祝我生日快乐。


尾声

另外本人有一个Flutter微信交流群,欢迎小伙伴加入,共同探讨Flutter的问题,期待与你的交流与切磋。

@张风捷特烈 2019.03.28 未允禁转
我的公众号:编程之王
联系我--邮箱:1981462002@qq.com --微信:zdl1994328
~ END ~