You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中如何实现点击Canvas画布选中并删除已绘制图形

Flutter 在线白板点击删除图形实现方案

核心逻辑:为所有图形统一实现点击命中判断,点击时按绘制层级从上到下匹配命中图形,直接从列表移除触发重绘。


具体实现步骤

  • 第一步:为Shape基类增加命中检测抽象方法
    把基础Shape类改为抽象类,强制所有图形子类实现点击命中判断逻辑,入参为点击的局部坐标,返回值代表当前坐标是否落在图形范围内。
    修改shape.dart中Shape定义:

    abstract class Shape {
      // 命中判断方法,子类各自实现
      bool hitTest(Offset point);
    }
    
  • 第二步:逐个实现各图形子类的命中判断规则
    不同图形的命中逻辑不同,结合描边宽度增加触控容差,降低手指点击的精准度要求:

    1. 正方形(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);
      }
    }
    
    1. 直线(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;
      }
    }
    
    1. 自由画笔(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;
      }
    }
    
  • 第三步:增加删除工具逻辑,匹配命中图形后移除

    1. 先在Tool枚举里新增erase(删除)选项,切换到删除工具时不触发原有绘制逻辑。
    2. 在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占满整个手势区域
      )
    )
    

注意事项

  1. 遍历图形必须从最后一个元素(最晚绘制、层级最高)开始判断,否则会出现点中上层图形却删掉下层图形的问题。
  2. 命中判断的容差可以根据需求调整,一般2-4像素在手机端手指触控的体验最好,不会出现点半天选不中的情况。
  3. 给CustomPaint明确设置size: Size.infinite,避免手势区域和绘制区域不匹配,出现坐标转换偏差。

内容的提问来源于stack exchange,提问作者Kevin Yang

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.28 18:54:23