通過交互繪製貝塞爾曲線

前言

之前幾篇我們介紹了貝塞爾曲線的原理、繪製曲線和動效實現,這些都是代碼預設好的,如果我們要根據需要自行繪製曲線,就需要使用交互來實現了。本篇我們先來介紹簡單的交互式繪圖,通過獲取觸控位置來設定貝塞爾曲線的控制點,從而實現交互式繪製曲線。

獲取觸控位置

第一個要解決的問題是如何獲取手指在屏幕的觸控位置。在 Flutter 中,提供了一個 Listener 組件,可以監聽各類觸控事件。Listener 的組件構造方法定義如下:

const Listener({
  Key? key,
  this.onPointerDown,
  this.onPointerMove,
  this.onPointerUp,
  this.onPointerHover,
  this.onPointerCancel,
  this.onPointerSignal,
  this.behavior = HitTestBehavior.deferToChild,
  Widget? child,
}) 

其中onPointerXX都是觸控事件的回調方法,在回調裏會攜帶觸控的位置信息,具體的參數如下:

  • onPointerDown:觸控點按下去(類似鼠標按下未釋放)時的回調方法,攜帶了一個PointerDownEvent參數,該參數會包含點按信息,其中有一個localPosition參數,即當時點按時的位置。
  • onPointerUp:觸控點按 鬆開(類似鼠標按下後釋放)時的回調方法,攜帶了一個PointerUpEvent參數,該參數會包含點按信息,其中有一個localPosition參數,即當時點按時的位置。
  • onPointerMove:按下後移動(拖動)時的回調方法,攜帶了 PointerMoveEvent參數,包含了起始位置和移動的距離等信息。
  • onPointerHover:這個很好理解,hover 狀態時的事件,不過在觸控設備上沒有 hover 事件。
  • onPointerCancel:產生 onPointerDown 事件的指針(Pointer)不再指向該接收器(有點費解,目前沒想到具體的應用場景,後續遇到了再研究一下)。
  • onPointerSignal:當指針發出其他信息時的回調,目前也沒想到具體的場景,可能是桌面應用會涉及到。
  • behavior:這個參數需要注意,也就是觸控事件的檢測方式,是一個HitTestBehavior枚舉,默認是deferToChild,只允許子組件響應觸控事件。opaque自身可以響應觸控事件(子組件和自己都可以),但下層的組件無法響應觸控事件。translucent爲穿透類型,下層組件也能夠響應到觸控事件。我們可以根據需要來確定觸控事件的響應方式。

下面是點按後獲取觸控位置爲例代碼,使用的話還是比較簡單的。當然,這裏要說明一下,如果只是單純地獲取觸控事件,而不需要觸控的細節(比如位置,觸控力度等),那麼官方推薦是使用更高封裝層級的手勢識別組件 GestureDetector

Listener(
  onPointerUp: ((event) {
    print('position: ${event.localPosition}');
  }),
  behavior: HitTestBehavior.opaque,
  child: ...
),

交互繪製實現

有了上面的基礎,我們獲取到觸控點位置後,刷新界面觸發 Canvas 重繪即可。不過這裏有個問題,我們希望是可以連續繪製,而不是隻能繪製一條曲線。比如說我們點按了2個點,那就繪製直線;3個點就繪製2階貝塞爾曲線;4個點繪製3階貝塞爾曲線;4個點以上,把前一條曲線的結束點當做新的曲線的起點,按上述的方式循環繪製。這樣我們就能夠保證曲線是連續的了。這裏可以通過遞歸方式實現,代碼如下:

void drawCurves(Canvas canvas, Paint paint, List<Offset> points) {
    if (points.length <= 1) {
      return;
    }
    if (points.length == 2) {
      canvas.drawLine(points[0], points[1], paint);
      return;
    }
    if (points.length == 3) {
      _draw2OrderBezierCurves(canvas, paint, points);
      return;
    }
    if (points.length == 4) {
      _draw3OrderBezierCurves(canvas, paint, points);
      return;
    }
    var subPoints = points.sublist(0, 4);
    drawCurves(canvas, paint, subPoints);
    drawCurves(canvas, paint, points.sublist(3));
}

此外,考慮可能繪製的不是我們想要的效果,要支持撤銷功能,因此我們加了一個按鈕,點擊按鈕可以刪除最近添加的點,從而實現撤銷功能。我們用 ScaffoldfloatingActionButton 實現,邏輯很簡單,就是如果點數組不爲空的話,就刪除最後一個點,然後刷新界面就好了。

floatingActionButton: IconButton(
  onPressed: () {
    if (points.isNotEmpty) {
      points.removeLast();
      setState(() {});
    }
  },
  icon: Icon(
    Icons.backspace,
    color: Colors.blue,
  ),
),

繪製代碼

有了上面的基礎,繪製代碼就簡單很多了,我們用一個數組存儲已經點擊的點,然後通過這些點遞歸調用繪製方法就可以 實現交互式繪製了,完整代碼如下所示,這裏我們把控制點使用圓圈繪製出來了。

class GestureBezierDemo extends StatefulWidget {
  GestureBezierDemo({Key? key}) : super(key: key);

  @override
  State<GestureBezierDemo> createState() => _GestureBezierDemoState();
}

class _GestureBezierDemoState extends State<GestureBezierDemo> {
  var points = <Offset>[];
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Listener(
        onPointerUp: ((event) {
          points.add(event.localPosition);
          setState(() {});
        }),
        behavior: HitTestBehavior.opaque,
        child: CustomPaint(
          foregroundPainter: GestureBezierPainter(points: points),
          child: Container(
            width: MediaQuery.of(context).size.width,
            height: MediaQuery.of(context).size.height,
            color: Color(0xFFF5F5F5),
          ),
        ),
      ),
      floatingActionButton: IconButton(
        onPressed: () {
          if (points.isNotEmpty) {
            points.removeLast();
            setState(() {});
          }
        },
        icon: Icon(
          Icons.backspace,
          color: Colors.blue,
        ),
      ),
    );
  }
}

class GestureBezierPainter extends CustomPainter {
  GestureBezierPainter({required this.points});
  final List<Offset> points;
  @override
  void paint(Canvas canvas, Size size) {
    print(size);
    canvas.drawColor(Color(0xFFF1F1F1), BlendMode.color);
    var paint = Paint()..color = Color(0xFFE53020);
    paint.strokeWidth = 2.0;
    paint.style = PaintingStyle.stroke;
    for (var point in points) {
      canvas.drawCircle(point, 2.0, paint);
    }
    paint.color = Color(0xFF2480F0);
    drawCurves(canvas, paint, points);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }

  void drawCurves(Canvas canvas, Paint paint, List<Offset> points) {
    if (points.length <= 1) {
      return;
    }
    if (points.length == 2) {
      canvas.drawLine(points[0], points[1], paint);
      return;
    }
    if (points.length == 3) {
      _draw2OrderBezierCurves(canvas, paint, points);
      return;
    }
    if (points.length == 4) {
      _draw3OrderBezierCurves(canvas, paint, points);
      return;
    }
    var subPoints = points.sublist(0, 4);
    drawCurves(canvas, paint, subPoints);
    drawCurves(canvas, paint, points.sublist(3));
  }

  _draw3OrderBezierCurves(Canvas canvas, Paint paint, List<Offset> points) {
    assert(points.length == 4);
    var yGap = 60.0;
    var path = Path();
    path.moveTo(points[0].dx, points[0].dy);
    for (var t = 1; t <= 100; t += 1) {
      var curvePoint = BezierUtil.get3OrderBezierPoint(
          points[0], points[1], points[2], points[3], t / 100.0);

      path.lineTo(curvePoint.dx, curvePoint.dy);
    }
    canvas.drawPath(path, paint);
  }

  _draw2OrderBezierCurves(Canvas canvas, Paint paint, List<Offset> points) {
    assert(points.length == 3);
    var path = Path();
    path.moveTo(points[0].dx, points[0].dy);
    for (var t = 1; t <= 100; t += 1) {
      var curvePoint = BezierUtil.get2OrderBezierPoint(
          points[0], points[1], points[2], t / 100.0);

      path.lineTo(curvePoint.dx, curvePoint.dy);
    }
    canvas.drawPath(path, paint);
  }
}

運行效果

運行效果如下圖所示。


總結

本篇其實更多地是介紹獲取屏幕觸控位置的內容,要實現交互式繪製就需要掌握用戶的觸控行爲,從而繪製對應的圖案。接下來我們會繼續探索更多交互行爲,做更多的交互式繪製探索。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章