• 手绘板的制作——画布缩放(4)


    前言

    在这一篇中,我们讲解下画布的缩放,也就是做一个根据手势缩放进行画布缩放的功能。

    我们先来梳理下逻辑:

    • 监听手势,当为一根手指的时候,就延续之前的操作,执行手绘操作,当操作为两根手指的时候,则执行缩放功能。
    • 对画布进行缩放

    好了,正文开始!

    手势缩放

    看了下,GestureDetector 里面有 onScaleStart、onScaleUpdate、onScaleEnd 参数,这…这不是缩放开始、缩放过程中、缩放结束的回调吗?Flutter 真方便,都给封装好了。赶紧试下:

      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onPanStart: (details) {
            print("onPanStart:准备开始移动");
            _paintedBoardProvider.onStart(details);
          },
          onPanUpdate: (details) {
            print("onPanUpdate:正在移动");
            _paintedBoardProvider.onUpdate(details);
          },
          onPanEnd: (details) {
            print("onPanDown:移动结束");
            widget._invoker.execute(PaintedCommand(
                _paintedBoardProvider, _paintedBoardProvider.strokes.last));
          },
          onScaleStart: (details) {  // <-  新增
            print("onScaleStart:缩放开始");
          },
          onScaleUpdate:  (details) { // <-  新增
            print("onScaleStart:缩放进行中");
          },
          onScaleEnd:  (details) { // <-  新增
            print("onScaleStart:缩放结束");
          },
          child: CustomPaint(
            painter: MyPainter(_paintedBoardProvider),
            size: Size.infinite,
          ),
        );
      }
    
    • 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
    • 31

    运行…

    ======== Exception caught by widgets library =======================================================
    The following assertion was thrown building HandPaintedBoard(dirty, state: _HandPaintedBoardState#7df2e):
    Incorrect GestureDetector arguments.
    
    Having both a pan gesture recognizer and a scale gesture recognizer is redundant; scale is a superset of pan.
    
    Just use the scale gesture recognizer.
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    em…咋报错了…

    大意就是,缩放手势包含了平移手势,所以,同时赋值缩放手势和平移手势是多余,直接使用缩放手势即可。

    具体报错源码就是:

             final bool havePan = onPanStart != null || onPanUpdate != null || onPanEnd != null;
             final bool haveScale = onScaleStart != null || onScaleUpdate != null || onScaleEnd != null;
             if (havePan || haveScale) {
               if (havePan && haveScale) {
                 throw FlutterError.fromParts([
                   ErrorSummary('Incorrect GestureDetector arguments.'),
                   ErrorDescription(
                     'Having both a pan gesture recognizer and a scale gesture recognizer is redundant; scale is a superset of pan.',
                   ),
                   ErrorHint('Just use the scale gesture recognizer.'),
                 ]);
               }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    这能怎么办?解决!

    所以,我们需要把 onPanStart、onPanUpdate、onPanEnd 去掉,只保留 onScaleStart、onScaleUpdate、onScaleEnd,然后在缩放的方法里面进行缩放和平移的区分,所以,我们先定义一个枚举:

    enum GestureType {
      translate, // 平移
      scale, // 缩放
    }
    
    • 1
    • 2
    • 3
    • 4

    具体区分代码:

    class _HandPaintedBoardState extends State {
      PaintedBoardProvider get _paintedBoardProvider =>
          widget._paintedBoardProvider;
      // 标识手势
      GestureType _gestureType = GestureType.translate;  // <- 新增
      // 记录缩放开始的缩放
      double _startScale = 1; // <- 新增
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    class PaintedBoardProvider extends ChangeNotifier {
      // 缩放比例
      double scale = 1;  // <- 新增
    
    • 1
    • 2
    • 3
          onScaleStart: (details) {
            if (details.pointerCount > 1) {  // 双指
              _gestureType = GestureType.scale;
              _startScale = _paintedBoardProvider.scale;
            } else { // 单指
              _gestureType = GestureType.translate;
              _paintedBoardProvider.onStart(details.localFocalPoint);
            }
          },
          onScaleUpdate: (details) {
            switch (_gestureType) {
              case GestureType.translate:
                _paintedBoardProvider.onUpdate(details.localFocalPoint);
                break;
              case GestureType.scale:
                  setState(() {
                    _paintedBoardProvider.scale = _startScale + details.scale - 1;
                  });
                break;
            }
          },
          onScaleEnd: (details) {
            switch (_gestureType) {
              case GestureType.translate:
                  widget._invoker.execute(PaintedCommand(
                      _paintedBoardProvider, _paintedBoardProvider.strokes.last));
                break;
              case GestureType.scale:
                print("onScaleEnd:缩放结束");
                break;
            }
          },
    
    • 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
    • 31
    • 32

    主要的思路其实就是:

    • 在 onScaleStart 的时候,判断是单指还是双指,并且进行记录该状态,后续的 onScaleUpdate、onScaleEnd 都是基于这个单指或者双指进行操作的。
    • 在 onScaleStart 中进行数据记录:
      • 单指:创建 stroke 存储当前绘画信息,便于后续手绘。
      • 双指:记录当前的缩放系数。
    • 在 onScaleUpdate 中进行状态更改:
      • 单指:更新 path 数据,进行手绘刷新。
      • 双指:手势过程的缩放系数 details.scale 是基于 1 进行不断增大的,直至缩放过程结束,所以通过 _startScale + details.scale - 1 就能拿到当前 Widget 正确的缩放系数,_startScale 为在 onScaleStart 中存储的当前缩放系数。
    • 在 onScaleEnd 进行事件的收尾处理:
      • 单指:提交命令。
      • 双指:无需操作。

    视图缩放

    经过以上步骤,我们可以获取得到手势缩放的系数,但是这个系数如何用于放大视图?

    目前一般有两种步骤:

    • 对于 canvas 进行缩放,并且对于 canvas 的绘制内容进行全部缩放,例如画笔原有起点为 (1,1),放大后,需要将画笔原有起点进行更改,可能就要变为 (2,2) 了,这种方式需要更改的比较多,所以我就不在这里实践了,有兴趣的同学可以自己试下。
    • 使用 Transform 进行缩放,也就是把整个 Widget 进行放大,所以 canvas 的坐标系是没有改动的,之前绘制的内容不需要重新绘制,目前我采取的是这种方案。这里有个重点,canvas 的坐标系是没有改动的

    所以,使用以下代码即可完成缩放功能:

          child: Transform.scale(
            scale: _paintedBoardProvider.scale,
            child: CustomPaint(
              painter: MyPainter(_paintedBoardProvider),
              size: Size.infinite,
            ),
          ),
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    所以,这缩放功能就结束了吗?

    当然没有这么简单,这后面才是难点。

    我们在进行手绘板制作的时候,使用的坐标点是 details.localFocalPoint,它是基于当前视图的坐标点,但是它的 (0,0) 坐标并不是固定为视图的左上角,当视图大于屏幕的时候,它的 (0,0) 是视图与屏幕的交接处,所以,无论使用 Transform 进行如何缩放,对于同一个点击点,其 details.localFocalPoint 的值都是一样的。(这话可能不够严谨,但是对于我当前的 demo 而言,它原有视图就是铺满整个屏幕,无论它使用 Transform 进行缩放多少倍,同个点击点的 details.localFocalPoint 值都是一样的。)

    但是,我们特别强调了,在进行缩放后,canvas 的坐标系是没有改动的,只是视图效果放大而已,所以,即使点击的是同一个位置,在 canvas 的坐标系上的位置也是不相同的,所以,我们要对于后续绘画的点进行处理,将 details.localFocalPoint 其转换为基于视图 (0,0) 点的坐标。

    在这里插入图片描述

    图片说明:

    • 蓝框为原图,x、y 为原图的坐标系。
    • Transform 默认是基于中心仅放大的,所以,黄框是实际上放大的效果。
    • 若放大前的 details.localFocalPoint 为 (10,10),那么放大后同个点击处的 details.localFocalPoint 仍然为 (10,10)
    • 由于手绘绘制是基于画布 (0,0) 位置的,也就是黄框的左上角,所以,我们需要把 details.localFocalPoint 加上两条绿边距离,才是真正的手绘坐标点。
    • 那两条绿边怎么计算?我们先算 x 坐标的,假设原图大小为 w1,放大后的大小为 w2,那绿边 x = (w2-w1) / 2,而 w2 其实就是 w1 乘以 scale,所以 x = (scale-1) * w1 /2

    实际的代码实操:

    首先,我们需要存储原有的画布大小:

    class PaintedBoardProvider extends ChangeNotifier {
    
      // 画布原有尺寸
      Size realCanvasSize = Size.zero;
    
    • 1
    • 2
    • 3
    • 4

    具体的赋值在 MyPainter:

    class MyPainter extends CustomPainter {
    
      @override
      void paint(Canvas canvas, Size size) {
        paintedBoardProvider.realCanvasSize = size;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    剩下的就是换算了:

    
      /// 移动开始时
      void onStart(Offset localPosition) {
        double startX = localPosition.dx;
        double startY = localPosition.dy;
        final newStroke = Stroke(
          color: isClear ? Colors.transparent : color, 
          width: paintWidth,
          isClear: isClear, 
        );
        newStroke.path.moveTo(
            (startX + (scale - 1) * realCanvasSize.width / 2 ) /
                scale,
            (startY + (scale - 1) * realCanvasSize.height / 2 ) /
                scale);
        _strokes.add(newStroke);
      }
    
      /// 移动
      void onUpdate(Offset localPosition) {
        _strokes.last.path.lineTo(
            (localPosition.dx +
                    (scale - 1) * realCanvasSize.width / 2 ) /
                scale,
            (localPosition.dy +
                    (scale - 1) * realCanvasSize.height / 2 ) /
                scale);
        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

    可能会有人有疑问,为什么换算后的值还要除以 scale,em…这还是因为 canvas 的坐标系没有更改过,我们的换算都是基于真正进行放大后的换算,但是实际上坐标系没有放大,所以还要除以 scale 转换回来。

    清除误差点

    在具体的实操上,其实人点击屏幕的时候,由于手指接触屏幕面积较大,所以,经常会出现缩放结束后,还会触发绘制的效果,所以,我们在手指抬起之后,对于绘制数据进行初步清理,也就是单点的误差的全部清除,当然,我这种方式还不够严谨,剩下的大家可以根据具体需求进行调整:

          onScaleEnd: (details) {
            switch (_gestureType) {
              case GestureType.translate:
                // 移除由于误操作导致的小点出现
                final lastBounds = _paintedBoardProvider.strokes.last.path.getBounds();
                if (lastBounds.width < 0.5 && lastBounds.height < 0.5) {
                  _paintedBoardProvider.strokes.removeLast();
                  _paintedBoardProvider.refreshPaintedBoard();
                } else {
                  widget._invoker.execute(PaintedCommand(
                      _paintedBoardProvider, _paintedBoardProvider.strokes.last));
                }
                break;
              case GestureType.scale:
                print("onScaleEnd:缩放结束");
                break;
            }
          },
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
  • 相关阅读:
    计算机毕业设计ssm+vue基本微信小程序的心理服务平台
    算法小白的心得笔记:分清楚执行程序和动态链接库的编译方式。
    基于时空双向注意力的乘车需求预测模型——以新冠肺炎期间北京市为例
    浅谈ChatGPT附免费体验地址
    Kafka(四) Consumer消费者
    Ruoyi-vue项目新建模块的SQL输出的配置
    markdown文件中的外链图片上传到GitHub图床
    Linux 通过 Docker 部署 Nacos 2.2.3 服务发现与配置中心
    3 个开源项目,让你感受程序员的浪漫!
    Vue3中defineEmits、defineProps 是怎么做到不用引入就能直接用的
  • 原文地址:https://blog.csdn.net/m0_46278918/article/details/125223821