【Flutter高階玩法】 貝塞爾曲線的表象認知

張風捷特烈發表於2020-03-28

零、前言

本文所有程式碼: 【github:https://github.com/toly1994328/flutter_play_bezier】

先看看本文要幹嘛:

-- --
【Flutter高階玩法】 貝塞爾曲線的表象認知
【Flutter高階玩法】 貝塞爾曲線的表象認知
【Flutter高階玩法】 貝塞爾曲線的表象認知
【Flutter高階玩法】 貝塞爾曲線的表象認知

在玩貝塞爾之前先做點準備活動熱熱身。打個網格對學習貝塞爾曲線是很有幫助的。如下是以中心為原點的座標系,x向右y向下

【Flutter高階玩法】 貝塞爾曲線的表象認知

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)點。

【Flutter高階玩法】 貝塞爾曲線的表象認知

/// 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描一筆,就出來一個曲線。

【Flutter高階玩法】 貝塞爾曲線的表象認知

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方法裡進行繪製輔助線。

【Flutter高階玩法】 貝塞爾曲線的表象認知

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);
}
複製程式碼
  • 看到上圖,你是不是發現的什麼?如果還比較懵,再畫一道輔助線

【Flutter高階玩法】 貝塞爾曲線的表象認知

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. 來玩一下這個曲線

這不就是三個點嘛,要能拖拖看就好了。沒問題,應你所求

【Flutter高階玩法】 貝塞爾曲線的表象認知

現在有兩個要點: 【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),
    ),
  );
}
複製程式碼

  • 繪製

網格和輔助的和上面邏輯基本一致,詳見原始碼,這裡就不貼了。當點數小於三個時,僅繪製觸點,否則繪製曲線和輔助線。

【Flutter高階玩法】 貝塞爾曲線的表象認知

有一點需要注意: 我們的點位是相對於螢幕左上角的,需要平移到畫布中心

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

【Flutter高階玩法】 貝塞爾曲線的表象認知


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中的鋼筆路徑就是多段的三貝曲線。所以還是很有玩頭的。

【Flutter高階玩法】 貝塞爾曲線的表象認知

--

  • 繪製擬圓

下面的圖看著像個圓,但其實是四段三貝擬合而成的。目前我們的程式碼中最在意的就是點位資料。所以關鍵就是尋找點。本小節原始碼在:circle_bezier.dart

【Flutter高階玩法】 貝塞爾曲線的表象認知

  • 第一段-左下

這裡直接給出點,至於0.551915024494是什麼,後面有機會會帶你一起推導。有興趣的話,你也可以自己查一查資料。和之前一樣,核心的繪製就是那麼一句。

【Flutter高階玩法】 貝塞爾曲線的表象認知

---->[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點放入列表。然後將賦值的點線繪製出來。

【Flutter高階玩法】 貝塞爾曲線的表象認知

---->[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.三貝中的擬圓的操作

看這控制柄,滿滿的拖動慾望,來實現一下吧
有了之前的鋪墊,下面的程式碼應該很容易接受吧。

【Flutter高階玩法】 貝塞爾曲線的表象認知

@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在手,天下我有

現在再準備一條路徑,看看路徑間的如何操作

【Flutter高階玩法】 貝塞爾曲線的表象認知

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

【Flutter高階玩法】 貝塞爾曲線的表象認知

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

2.路徑的相加: PathOperation.union

【Flutter高階玩法】 貝塞爾曲線的表象認知

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

3.路徑的反減: PathOperation.reverseDifference

【Flutter高階玩法】 貝塞爾曲線的表象認知

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

4.路徑的交集: PathOperation.intersect

【Flutter高階玩法】 貝塞爾曲線的表象認知

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

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

當然路徑並非是線條,也可以進行填色。

【Flutter高階玩法】 貝塞爾曲線的表象認知

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

相關文章