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

13

我希望让用户能够以一种不会产生线重叠甚至自交的方式绘制曲线。绘制曲线并没有问题,我甚至发现通过沿着线的节点前进和后退然后闭合路径,可以创建一个闭合且还是很像线的路径。

不幸的是,iOS只提供了测试点是否在闭合路径内的方法(containsPoint: 和 CGPathContainsPoint)。不幸的是,用户可以很容易地移动手指得足够快,使触摸点落在现有路径的两侧而实际上并没有被该路径所包含,因此测试触摸点是相当无意义的。

我找不到任何路径"相交"的方法。

对于如何完成这个任务,您有其他想法吗?


这个问题类似于另一个SO问题。https://dev59.com/IUjSa4cB1Zd3GeqPCRfk 那些答案建议查看每个单独的像素,这将会很慢。您可以通过myBezierPath.CGPath从您的UIBezierPath对象获取CGPathRef。 - Andrew
关于相似问题的发现不错。我正在研究一种比较连续位图的方法。一旦我有演示代码,我会放在这里。同时,我也会查看那个问题的答案。 - EFC
2个回答

6

我想到了一种方法来实现这个功能。虽然不完美,但考虑到这个问题已经被点赞了几次,我认为其他人也会想看看这种技术。我使用的技术是将要测试的所有项绘制到位图上下文中,然后将进度线的新段绘制到另一个位图上下文中。使用按位运算符比较这些上下文中的数据,如果发现任何重叠,则宣布命中。

这种技术的思想是测试新绘制线段的每个部分与之前绘制的所有线段甚至是同一条线段的早期部分相对比。换句话说,这种技术将检测到当一条线穿过另一条线时以及当它穿过自己时。

演示该技术的示例应用程序可在此处找到: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://stackoverflow.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;
}

我希望听到建议或看到改进。比如说,只检查新段的边界矩形而不是整个视图会更快。


2
提醒一下:我已经在示例应用程序中发现了一些错误,请确保您注意自己的实现。基本技术似乎有效,只是有一些可以改进的实现问题。我会继续调整示例,并进行更新,但我的主要重点将放在其他方面。 - EFC
嗨@EFC,我是社区的新手iOS程序员,你能具体指出哪里有防止相交的代码吗?我只需要那一部分。 - EdSniper
为了防止交叉,我只是想查看旧段和新段之间是否有任何共同的位。if ((completedData[i] | progressData[i]) & segmentData[i]) { 这一行就是实际测试。这个测试来自于 http://stackoverflow.com/a/6515999/383737。 - EFC

1

我将根据 Ole Begemann 的博客文章 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

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


我的代码解决方案

我使用UITapGestureRecognizer链接到tap()函数:

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)
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接