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


    前言

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

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

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

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

    class MyHomePage extends StatefulWidget {
    
      const MyHomePage({Key? key}) : super(key: key);
    
      @override
      State createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State {
    
      @override
      Widget build(BuildContext context) {
        return Container(
          color: Colors.white,
          child: CustomPaint(
            painter: MyPainter(),
          ),
        );
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    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
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    在这里插入图片描述

    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());
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在这里插入图片描述

    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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在这里插入图片描述

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

    手势

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

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

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

    class _MyHomePageState extends State {
      @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(),
            ),
          ),
        );
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    日志输出:

    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:移动结束
    
    • 1
    • 2
    • 3
    • 4
    • 5

    其实就是完成 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,
      });
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

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

    class PaintedBoardProvider extends ChangeNotifier {
    
      // 存储绘画数据
      final List _strokes = [];
      List 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();
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

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

    class _MyHomePageState extends State {
    
      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),
            ),
          ),
        );
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    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;
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

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

  • 相关阅读:
    Hbase 过滤器详解
    R语言、02 案例2-1 Pelican商店、《商务与经济统计》案例题
    在 Spring Boot2.7.1 中如何使用 Swagger3 指南
    提出创意的方法
    Python 教程之如何在 Python 中处理大型数据集CSV、Pickle、Parquet、Feather 和 HDF5 的比较
    MyBatisPlus(二十一)乐观锁
    电脑硬盘分区表的两种格式:MBR 和 GPT
    CentOS开机自动登陆root用户
    Docker实战技巧(一):常用命令与最佳实践
    微软 AR 眼镜新专利:包含热拔插电池
  • 原文地址:https://blog.csdn.net/m0_46278918/article/details/125125354