Flutter绘制之贝塞尔曲线画一个小海豚

1,252 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第9天,点击查看活动详情

  • 前言: 贝塞尔曲线的应用填补了计算机绘制与手绘之前的差距,更能表达人想画出的曲线,为了更好的理解万能的贝塞尔曲线,而海豚是我认为在海洋生物中身体曲线最完美的海洋生物,在海洋中游泳速度最高可达80km/h;比驱逐舰速度还快,学习绘制正好学到了贝塞尔曲线,那么我们今天就用贝塞尔曲线画看看能不能画一只可爱的小海豚呢。
  • 如果不了解贝塞尔曲线原理可以看下掘友的这篇文章-贝塞尔原理。 先上效果图:
    image.png
  • path路径绘制贝塞尔曲线的方法非常简单,只需要传入控制点即可,二阶就传1个控制点1个终点,三阶就传2个控制点和1个终点,但是要找到合适控制的点就没那么容易了,这时候我们如果可以用手指在屏幕上不断调试寻找合适的点岂不是非常方便,接下来我们就先实现下面的功能,通过手指不断调试控制点位并将多个贝塞尔曲线进行连接。

1649757651967.gif
可以看到一个三阶贝塞尔需要1个起点、2个控制点和1个终点组成,首先我们需要通过手势识别将这些控制点存储起来然后赋值给绘制组件进行更新就可以了,这里我们需要用到状态管理ChangeNotifier类,它继承Listenable,因为在绘制组件的构造方法里有一个参数repaint接受Listenable类型来控制是否重新绘制,数据变化就重新绘制。

const CustomPainter({ Listenable? repaint }) : _repaint = repaint;

因为CustomPainter的构造方法里的repaint参数就是负责更新绘制的,所以我们先要定义一个类继承ChangeNotifier来存储这些数据。
代码:

class TouchController extends ChangeNotifier {
  List<Offset> _points = []; //点集合
  int _selectIndex = -1;// 选中的点 更新位置用

  int get selectIndex => _selectIndex;

  List<Offset> get points => _points;

  // 选择某一个点 保存index
  set selectIndex(int value) {
    if (_selectIndex == value) return;
    _selectIndex = value;
    notifyListeners();// 通知刷新
  }
   // 选中的点标记
  Offset? get selectPoint => _selectIndex == -1 ? null : _points[_selectIndex];

  // 添加一个点
  void addPoint(Offset point) {
    points.add(point);
    notifyListeners();
  }
   // 手指移动时更新当前点的位置
  void updatePoint(int index, Offset point) {
    points[index] = point;
    notifyListeners();
  }
    // 删除最后一个点 相当于撤回上一步操作
  void removeLast() {
    points.removeLast();
    notifyListeners();
  }

}

有了存储数据的空间之后,我们就需要通过手势去获取这些点,通过手势在画布上的操作获取当前的位置进行存储以及更新。

 GestureDetector(
  child: CustomPaint(
    painter:
        _DolphinPainter(widget.touchController, widget.image),
  ),
  onPanDown: (d) {
    // 按压
    judgeZone(d.localPosition);
  },
  onPanUpdate: (d) {
    // 移动
    if (widget.touchController.selectIndex != -1) {
      widget.touchController.updatePoint(
          widget.touchController.selectIndex, d.localPosition);
    }
  },
)
///判断出是否在某点的半径为r圆范围内
bool judgeCircleArea(Offset src, Offset dst, double r) =>
    (src - dst).distance <= r;
///手指按下触发
void judgeZone(Offset src) {
  /// 循环所有的点
  for (int i = 0; i < widget.touchController.points.length; i++) {
    // 判断手指按的位置有没有按过的点
    if (judgeCircleArea(src, widget.touchController.points[i], 20)) {
      // 有点 不添加更新选中的点
      widget.touchController.selectIndex = i;
      return;
    }
  }
  // 无点 添加新的点 并将选中的点清空
  widget.touchController.addPoint(src);
  widget.touchController.selectIndex = -1;
}

到这里我们的手势按压和移动就会将数据存储到我们刚才定义的类中,接下来我们需要将这些数据赋予真正的绘制组件 CustomPainter

class _DolphinPainter extends CustomPainter {
  final TouchController touchController;// 存储数据类
//  final ui.Image image;

  _DolphinPainter(this.touchController, this.image)
    // 这个地方传入需要更新的 Listenable
      : super(repaint: touchController);

  List<Offset>? pos; //存储手势按压的点

  @override
  void paint(Canvas canvas, Size size) {
    // 画布原点平移到屏幕中央
    canvas.translate(size.width / 2, size.height / 2);
    // ,因为手势识别的原点是左上角,所以这里将存储的点相对的原点进行偏移到跟画布一致 负值向左上角偏移
    pos = touchController.points
        .map((e) => e.translate(-size.width / 2, -size.height / 2))
        .toList();

// 定义画笔
    var paint = Paint()
      ..strokeWidth = 2
      ..color = Colors.purple
      ..style = PaintingStyle.stroke
      ..isAntiAlias = true;

    // canvas.drawImage(image, Offset(-image.width / 2, -image.height / 2), paint);

    // 如果点小于4个 那么就只绘制点 如果>=4个点 那么就绘制贝塞尔曲线
    if (pos != null && pos!.length >= 4) {
      var path = Path();
      // 设置起点 手指第一个按压的点
      path.moveTo(pos![0].dx, (pos![0].dy));
      // path添加第一个贝塞尔曲线
      path.cubicTo(pos![1].dx,pos![1].dy, pos![2].dx, pos![2].dy, pos![3].dx,
          pos![3].dy);
          //绘制辅助线
      _drawHelpLine(canvas, size, paint, 0);
      // 绘制首个贝塞尔曲线
      canvas.drawPath(path, paint..color = Colors.purple);
      
      // for循环 绘制第2个以后的曲线 以上个终点为下一个的起点
      for (int i = 1; i < (pos!.length - 1) ~/ 3; i++) {
          //之后贝塞尔曲线的起点都是上一个贝塞尔曲线的终点
          // 比如第一个曲线 1,2,3,4.第二个就是4,5,6,7...以此类推,这样我们才能把线连接起来绘制图案
        // 这里把绘制之前的颜色覆盖
      // canvas.drawPath(path, paint..color = Colors.white);
        // 绘制辅助线
        _drawHelpLine(canvas, size, paint, i);
        //绘制贝塞尔曲线
        path.cubicTo(
          pos![i * 3 + 1].dx,
          pos![i * 3 + 1].dy,
          pos![i * 3 + 2].dx,
          pos![i * 3 + 2].dy,
          pos![i * 3 + 3].dx,
          pos![i * 3 + 3].dy,
        );

        if (i == 8) {
          path.close();
        }
        canvas.drawPath(path, paint..color = Colors.purple);
      }

      // 绘制辅助点
      _drawHelpPoint(canvas, paint);
      // 选中点
      _drawHelpSelectPoint(canvas, size, paint);
    } else {
      // 绘制辅助点
      _drawHelpPoint(canvas, paint);
    }


    // 画眼睛 眼睛位于起点的左侧,所以中心点向左偏移
    canvas.drawCircle(
        pos!.first.translate(-50, 5),
        10,
        paint
          ..color = Colors.black87
          ..style = PaintingStyle.stroke
          ..strokeWidth = 2);
    canvas.drawCircle(
        pos!.first.translate(-53, 5),
        7,
        paint
          ..color = Colors.black87
          ..style = PaintingStyle.fill);
  }
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
  return false;
}

void _drawHelpPoint(Canvas canvas, Paint paint) {
  canvas.drawPoints(
      PointMode.points,
      pos ?? [],
      paint
        ..strokeWidth = 10
        ..strokeCap = StrokeCap.round
        ..color = Colors.redAccent);
}

void _drawHelpSelectPoint(Canvas canvas, Size size, Paint paint) {
  Offset? selectPos = touchController.selectPoint;
  selectPos = selectPos?.translate(-size.width / 2, -size.height / 2);
  if (selectPos == null) return;
  canvas.drawCircle(
      selectPos,
      10,
      paint
        ..color = Colors.green
        ..strokeWidth = 2);
}

void _drawHelpLine(Canvas canvas, Size size, Paint paint, int i) {
  canvas.drawLine(
      Offset(pos![i * 3].dx, pos![i * 3].dy),
      Offset(pos![i * 3 + 1].dx, pos![i * 3 + 1].dy),
      paint
        ..color = Colors.redAccent
        ..strokeWidth = 2);

  canvas.drawLine(
      Offset(pos![i * 3 + 1].dx, pos![i * 3 + 1].dy),
      Offset(pos![i * 3 + 2].dx, pos![i * 3 + 2].dy),
      paint
        ..color = Colors.redAccent
        ..strokeWidth = 2);

  canvas.drawLine(
      Offset(pos![i * 3 + 2].dx, pos![i * 3 + 2].dy),
      Offset(pos![i * 3 + 3].dx, pos![i * 3 + 3].dy),
      paint
        ..color = Colors.redAccent
        ..strokeWidth = 2);
}

  • 最终在我们的手指的控制以及辅助线的帮助下,图案就慢慢的绘制出来了。 image.png
  • 去掉辅助线和点。 image.png
  • 然后将画笔改为填充,那么就得到我们一开始那副可爱的小海豚了。

总结

通过这个小海豚图案我们可以更加的理解贝塞尔曲线的绘制机制,通过你的手势控制,你也可以画出任何曲线和任何图案,可以说贝塞尔曲线就是绘制中的灵魂,掌握了贝塞尔曲线就相当于掌握了所有绘制组件,因为理论上来说,所有的二维图形都可以被贝塞尔曲线画出来,只要我们能准确的找到控制的点,就可以绘制无限可能的图案。

参考:掘金小册:Flutter妙笔生花