Search code examples
iosobjective-cquartz-graphicsuibezierpath

Detecting touch on a thick UIBezierPath


In a drawing section of my application, a user should be able to select a path they created. UIBezierPath has a handy method containsPoint: which works well in most cases, but it's exceptionally bad when the line drawn is close to straight with no curves. Getting your finger to hit a straight line is very hard.

It seems the method only checks a very narrow line along the path, and even setting lineWidth property of the path to a larger value has no effect on the results of the containsPoint: test.

I've looked at similar questions, like this one: Detect touch on UIBezierPath stroke, not fill but was unable to modify the solution to look at "an area around the path", it only seems to look at the thin line at the centre of it.

Edit

A working, but intensive solution that I came up with is to convert the path into a UIImage and check the colour of that image at the point requested. If the alpha component is greater than zero, the path intersects the touch.

This looks like this:

// pv is a view that contains our path as a property
// touchPoint is a CGPoint signifying the location of the touch
PathView *ghostPathView = [[PathView alloc] initWithFrame:pv.bounds];
ghostPathView.path = [pv.path copy]; // make a copy of the path
// 40 px is about thick enough to touch reliably
ghostPathView.path.lineWidth = 40; 
// the two magic methods getImage and getColorAtPoint: 
// have been copied from elsewhere on stackoverflow 
// and do exactly what you would expect from their names
UIColor *color = [[ghostPathView getImage] getColorAtPoint:touchPoint];
CGFloat alpha;
[color getWhite:nil alpha:&alpha];

if (alpha > 0){
// it's a match!
}

Here are the helper methods:

// in a category on UIImage
- (UIColor *)getColorAtPoint:(CGPoint)p {
    // return the colour of the image at a specific point

    // make sure the point lies within the image
    if (p.x >= self.size.width || p.y >= self.size.height || p.x < 0 || p.y < 0) return nil;

    // First get the image into your data buffer
    CGImageRef imageRef = [self CGImage];
    NSUInteger width = CGImageGetWidth(imageRef);
    NSUInteger height = CGImageGetHeight(imageRef);
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    unsigned char *rawData = (unsigned char*) calloc(height * width * 4, sizeof(unsigned char));
    NSUInteger bytesPerPixel = 4;
    NSUInteger bytesPerRow = bytesPerPixel * width;
    NSUInteger bitsPerComponent = 8;
    CGContextRef context = CGBitmapContextCreate(rawData, width, height,
                                                 bitsPerComponent, bytesPerRow, colorSpace,
                                                 kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
    CGColorSpaceRelease(colorSpace);

    CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
    CGContextRelease(context);

    // Now your rawData contains the image data in the RGBA8888 pixel format.
    int byteIndex = [[UIScreen mainScreen] scale] * ((bytesPerRow * p.y) + p.x * bytesPerPixel);
    CGFloat red   = (rawData[byteIndex]     * 1.0) / 255.0;
    CGFloat green = (rawData[byteIndex + 1] * 1.0) / 255.0;
    CGFloat blue  = (rawData[byteIndex + 2] * 1.0) / 255.0;
    CGFloat alpha = (rawData[byteIndex + 3] * 1.0) / 255.0;

    free(rawData);

    return [UIColor colorWithRed:red green:green blue:blue alpha:alpha];
}

// in a category on UIView
- (UIImage *)getImage {
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, NO, [[UIScreen mainScreen]scale]);
    [[self layer] renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *viewImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return viewImage;
}

Is there a more elegant way to do this?

Edit

Great answer by Satachito, just want to include it in Obj-C for completeness. I'm using 40 pt stroke as it's closer to Apple recommended minimum touch target size of 44x44 pixels.

CGPathRef pathCopy = CGPathCreateCopyByStrokingPath(originalPath.CGPath, nil, 40, kCGLineCapButt, kCGLineJoinMiter, 1);
UIBezierPath *fatOne = [UIBezierPath bezierPathWithCGPath:pathCopy];
if ([fatOne containsPoint:p]){
    // match found
}

Solution

  • Use 'CGPathCreateCopyByStrokingPath'

        let org = UIBezierPath( rect: CGRectMake( 100, 100, 100, 100 ) )
        let tmp = CGPathCreateCopyByStrokingPath(
            org.CGPath
        ,   nil
        ,   10
        ,   CGLineCap( 0 )  //  Butt
        ,   CGLineJoin( 0 ) //  Miter
        ,   1
        )
        let new = UIBezierPath( CGPath: tmp )
    

    Makes stroked path of original path. And test hit using both original and stroked path.