Flutter中如何实现点击Canvas画布选中并删除已绘制图形
Flutter 在线白板点击删除图形实现方案
核心逻辑:为所有图形统一实现点击命中判断,点击时按绘制层级从上到下匹配命中图形,直接从列表移除触发重绘。
具体实现步骤
第一步:为Shape基类增加命中检测抽象方法
把基础Shape类改为抽象类,强制所有图形子类实现点击命中判断逻辑,入参为点击的局部坐标,返回值代表当前坐标是否落在图形范围内。
修改shape.dart中Shape定义:abstract class Shape { // 命中判断方法,子类各自实现 bool hitTest(Offset point); }第二步:逐个实现各图形子类的命中判断规则
不同图形的命中逻辑不同,结合描边宽度增加触控容差,降低手指点击的精准度要求:- 正方形(Square):直接用矩形自带的包含判断,额外把矩形范围向外扩半个描边宽度+2像素容差,避免点到边框边缘判定失效:
class Square extends Shape { Rect? rect; double? left, top; final paint = Paint(); Square(Color color, double width) { this.paint ..color = color ..isAntiAlias = true ..strokeCap = StrokeCap.round ..strokeWidth = width; } @override bool hitTest(Offset point) { if (rect == null) return false; // 扩大判定范围,适配描边宽度和手指触控容差 final hitRect = rect!.inflate(paint.strokeWidth / 2 + 2); return hitRect.contains(point); } }- 直线(Line):计算点击位置到线段的垂直距离,距离小于描边宽度一半+容差即判定命中,先补一个点到线段距离的通用计算逻辑:
// 工具方法:计算点p到线段ab的最短距离 double _distanceToSegment(Offset p, Offset a, Offset b) { final double dx = b.dx - a.dx; final double dy = b.dy - a.dy; if (dx == 0 && dy == 0) return (p - a).distance; // 线段是一个点 final double t = ((p.dx - a.dx) * dx + (p.dy - a.dy) * dy) / (dx * dx + dy * dy); // 投影落在线段外,取到端点的距离 if (t < 0) return (p - a).distance; if (t > 1) return (p - b).distance; // 投影落在线段上,计算垂直距离 final Offset projection = Offset(a.dx + t * dx, a.dy + t * dy); return (p - projection).distance; } class Line extends Shape { Offset? p1, p2; final paint = Paint(); Line(Color color, double width) { this.paint ..color = color ..isAntiAlias = true ..strokeCap = StrokeCap.round ..strokeWidth = width; } @override bool hitTest(Offset point) { if (p1 == null || p2 == null) return false; final distance = _distanceToSegment(point, p1!, p2!); // 距离小于描边半宽+2像素容差即命中 return distance < paint.strokeWidth / 2 + 2; } }- 自由画笔(Pen):画笔轨迹是多个连续点组成的折线,遍历所有相邻点组成的小线段,只要任意一段满足直线的命中规则,就判定命中:
class Pen extends Shape { final List<Offset> points = []; final paint = Paint(); Pen(Color color, double width) { this.paint ..color = color ..isAntiAlias = true ..strokeCap = StrokeCap.round ..strokeWidth = width; } void add(Offset point) => points.add(point); @override bool hitTest(Offset point) { if (points.length < 2) return points.isNotEmpty && (points.first - point).distance < paint.strokeWidth/2 +2; // 遍历每一段折线 for (int i = 0; i < points.length - 1; i++) { final distance = _distanceToSegment(point, points[i], points[i+1]); if (distance < paint.strokeWidth / 2 + 2) { return true; } } return false; } }第三步:增加删除工具逻辑,匹配命中图形后移除
- 先在Tool枚举里新增erase(删除)选项,切换到删除工具时不触发原有绘制逻辑。
- 在GestureDetector中记录按下的位置,抬起时判断如果是点击(位移小于3像素,避免和拖拽绘制冲突),就拿到点击的局部坐标,倒序遍历shapes列表(列表越靠后的元素绘制层级越靠上,优先判断顶层图形,避免误删下层被遮挡的图形),找到第一个hitTest返回true的图形,从列表移除后触发重绘即可。
对应手势部分修改示例:
// 新增变量记录按下位置,区分点击和拖拽 Offset? _dragStartPoint; GestureDetector( onScaleStart: (details) { Tool tool = context.read<DrawProvider>().tool; RenderBox box = context.findRenderObject() as RenderBox; Offset point = box.globalToLocal(details.focalPoint); _dragStartPoint = point; // 原有绘制逻辑,仅在非删除工具下执行 if (tool != Tool.erase) { // 这里放你原来的pen、square等工具的onScaleStart逻辑 switch (tool) { // ... 原有代码不变 } } }, onScaleUpdate: (details) { Tool tool = context.read<DrawProvider>().tool; RenderBox box = context.findRenderObject() as RenderBox; Offset point = box.globalToLocal(details.focalPoint); if (tool != Tool.erase) { // 这里放你原来的pen、square等工具的onScaleUpdate逻辑 switch (tool) { // ... 原有代码不变 } } }, onScaleEnd: (details) { Tool tool = context.read<DrawProvider>().tool; RenderBox box = context.findRenderObject() as RenderBox; final endPoint = box.globalToLocal(details.focalPoint); // 删除工具逻辑:判断是点击而非拖拽 if (tool == Tool.erase && _dragStartPoint != null) { if ((endPoint - _dragStartPoint!).distance < 3) { // 倒序遍历,优先命中顶层图形 for (int i = shapes.length - 1; i >= 0; i--) { final shape = shapes[i]; if (shape.hitTest(endPoint)) { shapes.removeAt(i); // 这里触发你的重绘逻辑,比如setState或者通知provider setState((){}); break; // 只删最顶层的命中图形,删完退出 } } } } _dragStartPoint = null; }, child: CustomPaint( painter: Sketcher(shapes), size: Size.infinite, // 建议加上,确保CustomPaint占满整个手势区域 ) )
注意事项
- 遍历图形必须从最后一个元素(最晚绘制、层级最高)开始判断,否则会出现点中上层图形却删掉下层图形的问题。
- 命中判断的容差可以根据需求调整,一般2-4像素在手机端手指触控的体验最好,不会出现点半天选不中的情况。
- 给CustomPaint明确设置
size: Size.infinite,避免手势区域和绘制区域不匹配,出现坐标转换偏差。
内容的提问来源于stack exchange,提问作者Kevin Yang
相关产品推荐
相关产品推荐

