【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 ~

相關文章
相關標籤/搜索