手绘板的制作——手绘(1)

2,204 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第16天,点击查看活动详情

前言

通过上一篇文章「如何优雅地画一张图」我们已经知道如何在画布里面绘画一张图了,这次我准备开一个系列讲解下手绘板的制作,可能包含:

  • 手绘
  • 橡皮擦
  • 撤销
  • 重制
  • 重置
  • 图片导出
  • 命令模式

等功能。具体等到时候想到什么再写什么。

废话不多说,我们还是先来保证能够画个矩形:

class MyHomePage extends StatefulWidget {

  const MyHomePage({Key? key}) : super(key: key);

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: CustomPaint(
        painter: MyPainter(),
      ),
    );
  }
}
class MyPainter extends CustomPainter {

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(const Rect.fromLTRB(50, 50, 200, 200), Paint());
  }

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

手绘板1.png

drawPath

为什么要先了解 drawPath ?而不是手势?这是因为手绘其实就是根据手指的移动进行绘制,而这个绘制就是用 drawPath 来实现的,只要学会了 drawPath,后续根据手指的移动进行 path 的制作,然后再进行绘制即可。

我们来看下其 API:

void drawPath(Path path, Paint paint)

关于 path 的时候有很多种方式,这里就不进行详解了,我们目前只需要用到 void moveTo(double x, double y)void lineTo(double x, double y),等后续用到其它的再进行额外说明。

  • moveTo:将绘制点移动到某个位置。
  • lineTo:将当前绘制点与目标绘制点进行链接,并且将目标绘制点设置为当前绘制点。

然后我们通过一个简单的示例来看看效果:

    final path = Path()
      ..moveTo(150, 30)
      ..lineTo(25, 60)
      ..lineTo(70, 100)
      ..lineTo(100, 50);
    canvas.drawPath(path, Paint());

手绘板2.png

em...没有抗锯齿和默认填充了,我们来改改 Paint:

    final paint = Paint()
    ..isAntiAlias = true
    ..style = PaintingStyle.stroke;
    final path = Path()
      ..moveTo(150, 30)
      ..lineTo(25, 60)
      ..lineTo(70, 100)
      ..lineTo(100, 50);
    canvas.drawPath(path, paint);

手绘板3.png

这就跟我们想象中的差不多了。

手势

在写代码前,我们先梳理下逻辑:

  • 将每次的完整手势流程存储为一个 path。如何定义是一个完整的手势流程?那就是手指从按下、到移动、到抬起,就是一次完整的手势流程。
  • 按下操作,其实就是记录 path 的 moveTo()
  • 移动操作,其实就是记录 path 的 lineTo()
  • 抬起操作,其实就是标识本次手势流程结束了。

下面我们来看下,具体代码该怎么实现。

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: GestureDetector(
        onPanDown: (details){
          print("onPanDown:刚按下,x:${details.localPosition.dx},y:${details.localPosition.dy}");
        },
        onPanStart: (details){
          print("onPanStart:开始移动,x:${details.localPosition.dx},y:${details.localPosition.dy}");
        },
        onPanUpdate: (details){
          print("onPanUpdate:移动,x:${details.localPosition.dx},y:${details.localPosition.dy}");
        },
        onPanEnd: (details){
          print("onPanDown:移动结束");
        },
        child: CustomPaint(
          painter: MyPainter(),
        ),
      ),
    );
  }
}

日志输出:

I/flutter: onPanDown:刚按下,x:205.14285714285714,y:273.14285714285717
I/flutter: onPanStart:开始移动,x:205.14285714285714,y:273.14285714285717
I/flutter: onPanUpdate:移动,x:205.14285714285714,y:273.14285714285717
I/flutter: onPanUpdate:移动,x:205.14285714285714,y:273.14285714285717
I/flutter: onPanDown:移动结束

其实就是完成 onPanDown、onPanStart、onPanUpdate、onPanEnd 的回调书写,不过由于 onPanDown 和 onPanStart 功能较为相近,并且 onPanStart 明确为移动开始就回调,所以我们后续就只使用 onPanStart,不使用 onPanDown。

下面我们新建一个类来存储当前绘画相关信息:

class Stroke {
  final path = Path();  // 绘画路径
  Color color;  // 画笔颜色
  double width;  // 画笔粗细

  Stroke({
    this.color = Colors.black,
    this.width = 3,
  });
}

然后新建一个 ChangeNotifier 来存储绘画的相关操作,同时也便于后续更新,因为我们每次绘画其实都是需要刷新画布,将最新效果绘画出来:

class PaintedBoardProvider extends ChangeNotifier {

  // 存储绘画数据
  final List<Stroke> _strokes = [];
  List<Stroke> get strokes => _strokes;

  // 颜色
  var color = Colors.greenAccent;
  // 笔画宽度
  double paintWidth = 3;

  /// 移动开始时
  void onStart(DragStartDetails details) {
    double startX = details.localPosition.dx;
    double startY = details.localPosition.dy;
    final newStroke = Stroke(
      color: color,
      width: paintWidth,
    );
    newStroke.path.moveTo(startX, startY);
    _strokes.add(newStroke);
  }

  /// 移动
  void onUpdate(DragUpdateDetails details) {
    _strokes.last.path
        .lineTo(details.localPosition.dx, details.localPosition.dy);
    notifyListeners();
  }
}

将 GestureDetector 与 PaintedBoardProvider 进行关联,同时也把 PaintedBoardProvider 传递给 MyPainter,因为绘画时需要用到 PaintedBoardProvider 的数据,同时刷新时也需要用到 PaintedBoardProvider。

class _MyHomePageState extends State<MyHomePage> {

  final PaintedBoardProvider _paintedBoardProvider = PaintedBoardProvider();

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: GestureDetector(
        onPanStart: (details){
          _paintedBoardProvider.onStart(details);
        },
        onPanUpdate: (details){
          _paintedBoardProvider.onUpdate(details);
        },
        onPanEnd: (details){
          print("onPanDown:移动结束");
        },
        child: CustomPaint(
          painter: MyPainter(_paintedBoardProvider),
        ),
      ),
    );
  }
}
class MyPainter extends CustomPainter {

  MyPainter(this.paintedBoardProvider)
      : super(repaint: paintedBoardProvider);

  final PaintedBoardProvider paintedBoardProvider;

  @override
  void paint(Canvas canvas, Size size) {
    // 获取绘画数据进行绘画
    for (final stroke in paintedBoardProvider.strokes) {
      final paint = Paint()
        ..strokeWidth = stroke.width
        ..color = stroke.color
        ..strokeCap = StrokeCap.round
        ..style = PaintingStyle.stroke;
      canvas.drawPath(stroke.path, paint);
    }
  }

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

这里有一点要特别注意,那就是 MyPainter(this.paintedBoardProvider): super(repaint: paintedBoardProvider);,这是因为 PaintedBoardProvider 调用 notifyListeners() 的时候,并不会像之前那样刷新 ChangeNotifierProvider 布局,而是直接刷新 MyPainter。(我们这里没有用 ChangeNotifierProvider 或者 setState(() {}); 去刷新布局,其实是个小优化,有时间可以讲下。)