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

1,312 阅读6分钟

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

前言

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

我们先来梳理下逻辑:

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

好了,正文开始!

手势缩放

看了下,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,
      ),
    );
  }

运行...

======== 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.

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(<DiagnosticsNode>[
               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.'),
             ]);
           }

这能怎么办?解决!

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

enum GestureType {
  translate, // 平移
  scale, // 缩放
}

具体区分代码:

class _HandPaintedBoardState extends State<HandPaintedBoard> {
  PaintedBoardProvider get _paintedBoardProvider =>
      widget._paintedBoardProvider;
  // 标识手势
  GestureType _gestureType = GestureType.translate;  // <- 新增
  // 记录缩放开始的缩放
  double _startScale = 1; // <- 新增
class PaintedBoardProvider extends ChangeNotifier {
  // 缩放比例
  double scale = 1;  // <- 新增
      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;
        }
      },

主要的思路其实就是:

  • 在 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,
        ),
      ),

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

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

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

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

画布缩放1.png

  • 蓝框为原图,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;

具体的赋值在 MyPainter:

class MyPainter extends CustomPainter {

  @override
  void paint(Canvas canvas, Size size) {
    paintedBoardProvider.realCanvasSize = size;

剩下的就是换算了:


  /// 移动开始时
  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();
  }

可能会有人有疑问,为什么换算后的值还要除以 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;
        }
      },