在 iOS 中绘制线条时的命中检测

Posted

技术标签:

【中文标题】在 iOS 中绘制线条时的命中检测【英文标题】:Hit detection when drawing lines in iOS 【发布时间】:2011-06-26 05:46:26 【问题描述】:

我想允许用户以这样一种方式绘制曲线,即任何一条线都不能与另一条线相交,甚至不能与自身相交。绘制曲线是没有问题的,我什至发现我可以通过向前和向后跟踪线的节点然后关闭路径来创建一个封闭的并且仍然很像线的路径。

不幸的是,ios 只提供了一个点是否包含在封闭路径中的测试(containsPoint: 和 CGPathContainsPoint)。不幸的是,用户可以很容易地快速移动手指,使触摸点落在现有路径的两侧,而实际上并没有被该路径包含,因此测试触摸点毫无意义。

我找不到任何路径方法的“交叉点”。

关于如何完成此任务的任何其他想法?

【问题讨论】:

这个问题类似于另一个 SO Question。 ***.com/questions/1021801/cgpathref-intersection 这些答案建议查看每个单独的像素,这会很慢。您可以通过 myBezierPath.CGPath 从 UIBezierPath 对象中获取 CGPathRef 很好地了解了类似的问题。我正在研究一种比较连续位图的方法。一旦我有演示代码,我会把它放在这里。同时,我也会查看该问题的答案。 【参考方案1】:

嗯,我确实想出了一个方法来做到这一点。它是不完美的,但我认为其他人可能希望看到该技术,因为这个问题被多次投票。我使用的技术将所有要测试的项目绘制到位图上下文中,然后将进度线的新段绘制到另一个位图上下文中。这些上下文中的数据使用按位运算符进行比较,如果发现任何重叠,则声明命中。

此技术背后的想法是针对所有先前绘制的线,甚至针对同一条线的较早部分,测试新绘制的线的每一段。换句话说,这种技术将检测一条线何时与另一条线相交以及何时与自身相交。

提供了演示该技术的示例应用程序:LineSample.zip。

点击测试的核心是在我的 LineView 对象中完成的。这里有两个关键方法:

- (CGContextRef)newBitmapContext 

    // creating b&w bitmaps to do hit testing
    // based on: http://robnapier.net/blog/clipping-cgrect-cgpath-531
    // see "Supported Pixel Formats" in Quartz 2D Programming Guide
    CGContextRef bitmapContext =
    CGBitmapContextCreate(NULL, // data automatically allocated
                          self.bounds.size.width,
                          self.bounds.size.height,
                          8, 
                          self.bounds.size.width,
                          NULL,
                          kCGImageAlphaOnly);
    CGContextSetShouldAntialias(bitmapContext, NO);
    // use CGBitmapContextGetData to get at this data

    return bitmapContext;



- (BOOL)line:(Line *)line canExtendToPoint:(CGPoint) newPoint 

    //  Lines are made up of segments that go from node to node. If we want to test for self-crossing, then we can't just test the whole in progress line against the completed line, we actually have to test each segment since one segment of the in progress line may cross another segment of the same line (think of a loop in the line). We also have to avoid checking the first point of the new segment against the last point of the previous segment (which is the same point). Luckily, a line cannot curve back on itself in just one segment (think about it, it takes at least two segments to reach yourself again). This means that we can both test progressive segments and avoid false hits by NOT drawing the last segment of the line into the test! So we will put everything up to the  last segment into the hitProgressLayer, we will put the new segment into the segmentLayer, and then we will test for overlap among those two and the hitTestLayer. Any point that is in all three layers will indicate a hit, otherwise we are OK.

    if (line.failed) 
        // shortcut in case a failed line is retested
        return NO;
    
    BOOL ok = YES; // thinking positively

    // set up a context to hold the new segment and stroke it in
    CGContextRef segmentContext = [self newBitmapContext];
    CGContextSetLineWidth(segmentContext, 2); // bit thicker to facilitate hits
    CGPoint lastPoint = [[[line nodes] lastObject] point];
    CGContextMoveToPoint(segmentContext, lastPoint.x, lastPoint.y);
    CGContextAddLineToPoint(segmentContext, newPoint.x, newPoint.y);
    CGContextStrokePath(segmentContext);

    // now we actually test
    // based on code from benzado: http://***.com/questions/6515885/how-to-do-comparisons-of-bitmaps-in-ios/6515999#6515999
    unsigned char *completedData = CGBitmapContextGetData(hitCompletedContext);
    unsigned char *progressData = CGBitmapContextGetData(hitProgressContext);
    unsigned char *segmentData = CGBitmapContextGetData(segmentContext);

    size_t bytesPerRow = CGBitmapContextGetBytesPerRow(segmentContext);
    size_t height = CGBitmapContextGetHeight(segmentContext);
    size_t len = bytesPerRow * height;

    for (int i = 0; i < len; i++) 
        if ((completedData[i] | progressData[i]) & segmentData[i])  
            ok = NO; 
            break; 
        
    

    CGContextRelease(segmentContext);

    if (ok) 
        // now that we know we are good to go, 
        // we will add the last segment onto the hitProgressLayer
        int numberOfSegments = [[line nodes] count] - 1;
        if (numberOfSegments > 0) 
            // but only if there is a segment there!
            CGPoint secondToLastPoint = [[[line nodes] objectAtIndex:numberOfSegments-1] point];
            CGContextSetLineWidth(hitProgressContext, 1); // but thinner
            CGContextMoveToPoint(hitProgressContext, secondToLastPoint.x, secondToLastPoint.y);
            CGContextAddLineToPoint(hitProgressContext, lastPoint.x, lastPoint.y);
            CGContextStrokePath(hitProgressContext);
        
     else 
        line.failed = YES;
        [linesFailed addObject:line];
    
    return ok;

我很想听听建议或看到改进。一方面,只检查新段的边界矩形而不是整个视图会快得多。

【讨论】:

公平警告:我已经在示例应用程序中发现了一些错误,因此请务必注意自己的实现。基本技术似乎有效,只是一些可以改进的实现问题。我将进一步修改示例并保持更新,但我的主要关注点将在其他地方。 嗨@EFC,我对社区和新手iOS程序员有点陌生,你能具体指出它阻止自身相交的代码在哪里吗?我只需要那部分。 为了防止相交,我只是想看看新旧段之间是否有任何共同点。 if ((completedData[i] | progressData[i]) &amp; segmentData[i]) 行是实际测试的内容。该测试来自***.com/a/6515999/383737。【参考方案2】:

Swift 4,答案基于CGPath Hit Testing - Ole Begemann (2012)

来自 Ole Begemann 博客:

contains(point: CGPoint)

如果您想对整个区域进行命中测试,此功能很有帮助 路径覆盖。因此,contains(point: CGPoint) 不适用于 未封闭的路径,因为它们没有内部 填满。

copy(strokingWithWidth lineWidth: CGFloat, lineCap: CGLineCap, lineJoin: CGLineJoin, miterLimit: CGFloat, transform: CGAffineTransform = default) -> CGPath

这个函数创建一个镜像点击目标对象,它只覆盖 路径的描边区域。当用户点击屏幕时,我们 迭代点击目标而不是实际形状。

我的代码解决方案

我使用了一个链接到函数 tap() 的 UITapGestureRecognizer:

var bezierPaths = [UIBezierPath]()   // containing all lines already drawn
var tappedPaths = [CAShapeLayer]()

@IBAction func tap(_ sender: UITapGestureRecognizer)         
    let point = sender.location(in: imageView)

    for path in bezierPaths 
        // create tapTarget for path
        if let target = tapTarget(for: path) 
            if target.contains(point) 
                tappedPaths.append(layer)
            
        
    


fileprivate func tapTarget(for path: UIBezierPath) -> UIBezierPath 

    let targetPath = path.copy(strokingWithWidth: path.lineWidth, lineCap: path..lineCapStyle, lineJoin: path..lineJoinStyle, miterLimit: path.miterLimit)

    return UIBezierPath.init(cgPath: targetPath)

【讨论】:

以上是关于在 iOS 中绘制线条时的命中检测的主要内容,如果未能解决你的问题,请参考以下文章

iOS 动画绘制线条颜色渐变的折线图

以增加的角度绘制线条

在 HTML Canvas 中绘制线条以围绕形状进行路由

为啥使用 cv::line 函数绘制的不同线条具有不同的透明度 (alpha) 级别?

绘制圆线iOS Swift

PyQt5 将线条延伸到正在绘制的图像之外。我怎样才能防止这种情况发生?