PAYATHON 2026

Flutter 如何实现双指变换与单指画布绘制?

数据小许

结论

可以让 GestureDetector 处理双指变换,同时用 Listener 监听原始指针事件并绘画。第一个指针按下时开始记录笔迹;第二个指针按下后结束当前笔迹。只有两个或更多指针时,才更新变换。绘制前,使用画布对应的 RenderBox.globalToLocal 将指针的全局坐标转换为画布局部坐标。

画布是嵌在 Transform.translate、Transform.rotate 和 Transform.scale 中的 CustomPaint。坐标转换应基于 CustomPaint 对应的 RenderBox,这样才能取得画布局部坐标。

记录单指笔迹

在 State 中添加画布 key、笔迹集合和活动指针状态:

final GlobalKey _canvasKey = GlobalKey();
final List<List<Offset>> _strokes = [];
final Set<int> _pointers = {};
int? _drawingPointer;

Offset? _toCanvasLocal(Offset globalPosition) {
  final renderObject = _canvasKey.currentContext?.findRenderObject();
  if (renderObject is! RenderBox) return null;
  return renderObject.globalToLocal(globalPosition);
}

void _addPoint(Offset globalPosition) {
  final local = _toCanvasLocal(globalPosition);
  if (local == null) return;

  setState(() {
    if (_strokes.isEmpty || _drawingPointer == null) {
      _strokes.add([local]);
    } else {
      _strokes.last.add(local);
    }
  });
}

void _onPointerDown(PointerDownEvent event) {
  _pointers.add(event.pointer);

  if (_pointers.length == 1) {
    _drawingPointer = event.pointer;
    _addPoint(event.position);
  } else {
    // 第二个指针加入后,结束当前笔迹,交由双指手势处理。
    _drawingPointer = null;
  }
}

void _onPointerMove(PointerMoveEvent event) {
  if (_drawingPointer == event.pointer && _pointers.length == 1) {
    _addPoint(event.position);
  }
}

void _onPointerUp(PointerEvent event) {
  _pointers.remove(event.pointer);
  if (_drawingPointer == event.pointer) _drawingPointer = null;
}

将 Listener 放在能接收画布触摸事件的区域,并把 key 设到 CustomPaint 上:

Listener(
  onPointerDown: _onPointerDown,
  onPointerMove: _onPointerMove,
  onPointerUp: _onPointerUp,
  onPointerCancel: _onPointerUp,
  child: CustomPaint(
    key: _canvasKey,
    size: canvasSize,
    painter: MyPainter(_strokes),
  ),
)

在现有布局中,可以把这个 Listener 放进 GestureDetector 的子树,并保留 CustomPaint 外面的三个 Transform。Transform 默认会在命中测试时反向转换坐标,因此不要将 transformHitTests 设为 false。event.position 提供全局坐标,globalToLocal 会根据画布在渲染树中的位置和变换关系,计算绘制所需的局部坐标。

双指时才更新变换

当前的 onScaleUpdate 在单指移动时也会收到更新。检查指针数,达到两个时再应用旋转、缩放和平移,这样单指绘画时画布不会同时移动:

void _handleOnScaleUpdate(ScaleUpdateDetails details) {
  if (details.pointerCount < 2) return;

  setState(() {
    currentRotation = initialRotation + details.rotation;
    currentScale = initialScale * details.scale;
    currentDisplacement =
        initialDisplacement + (details.focalPoint - initialFocalPoint);
  });
}

_handleOnScaleStart 和 _handleOnScaleEnd 仍可用于保存变换开始值和结束值。focalPoint 是全局坐标,计算平移时,手势开始和更新阶段都要使用同一坐标系。上面的代码符合这一点。

在 CustomPainter 中绘制

让 painter 接收笔迹列表,并逐条绘制路径。下面省略原有背景矩形和圆形的绘制代码:

class MyPainter extends CustomPainter {
  MyPainter(this.strokes);

  final List<List<Offset>> strokes;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.red
      ..strokeWidth = 3
      ..strokeCap = StrokeCap.round
      ..strokeJoin = StrokeJoin.round
      ..style = PaintingStyle.stroke;

    for (final points in strokes) {
      if (points.isEmpty) continue;

      if (points.length == 1) {
        canvas.drawCircle(points.first, paint.strokeWidth / 2, paint);
        continue;
      }

      final path = Path()..moveTo(points.first.dx, points.first.dy);
      for (final point in points.skip(1)) {
        path.lineTo(point.dx, point.dy);
      }
      canvas.drawPath(path, paint);
    }
  }

  @override
  bool shouldRepaint(covariant MyPainter oldDelegate) => true;
}

注意事项

  • _canvasKey 必须对应实际绘制区域的 CustomPaint。画布经过变换后,如果使用 Listener 的局部坐标或外层 RenderBox,笔迹可能会偏移。
  • 第二根手指加入时,上面的逻辑会结束当前笔迹。第二根手指离开后不会自动续画,需要再次按下单指才能开始新笔迹。
  • 如果 CustomPaint 尺寸小于整个可触摸区域,建议限制绘制范围,并根据需要裁剪画布内容。
  • _strokes 持续增长时,每次都会重绘全部历史路径。笔迹较多时,可以缓存已完成的笔迹,或用 Listenable 更新 painter,以减少重建开销。

备注:内容仅供参考。