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,以减少重建开销。
备注:内容仅供参考。
