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

零、前言

本文全部代码: 【github:https://github.com/toly1994328/flutter_play_bezier】git

先看看本文要干吗:github

-- --

在玩贝塞尔以前先作点准备活动热热身。打个网格对学习贝塞尔曲线是颇有帮助的。以下是以中心为原点的坐标系,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组件显示画布

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

/// 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)点。 bash

/// 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.四、人生至美莫初见

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

  • 看到贝塞尔三个字,也不用以为压力太大,满打满算也就两个函数而已。
---->[二次贝塞尔曲线]----
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)
复制代码

1、二次贝塞尔曲线

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

1. 先画一笔看看

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

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方法里进行绘制辅助线。ide

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设置起点,当绘制连续的贝塞尔曲线,下一段曲线的起点就是上一段的终点。因此二次贝塞尔曲线相当重要的是两个点: 也就是入参中的控制点和终点


2、三次贝塞尔曲线

前面的二次贝塞尔实现了,那如今来看三次的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;
    }
  }
}
复制代码

3、贝塞尔曲线与路径操做

也许你以为贝塞尔曲线也就那样。那么你忽略了一个很重要的东西。
贝塞尔曲线是一条路径。路径是个什么东西,以前写了一篇关于路径使用的冰山一角
【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 ~

相关文章
相关标签/搜索