How to detect taps on MKPolylines/Overlays like Maps.app?

myell0w picture myell0w · Jul 30, 2012 · Viewed 8.5k times · Source

When displaying directions on the built-in Maps.app on the iPhone you can "select" one of the usually 3 route alternatives that are displayed by tapping on it. I wan't to replicate this functionality and check if a tap lies within a given MKPolyline.

Currently I detect taps on the MapView like this:

// Add Gesture Recognizer to MapView to detect taps
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleMapTap:)];

// we require all gesture recognizer except other single-tap gesture recognizers to fail
for (UIGestureRecognizer *gesture in self.gestureRecognizers) {
    if ([gesture isKindOfClass:[UITapGestureRecognizer class]]) {
        UITapGestureRecognizer *systemTap = (UITapGestureRecognizer *)gesture;

        if (systemTap.numberOfTapsRequired > 1) {
            [tap requireGestureRecognizerToFail:systemTap];
        }
    } else {
        [tap requireGestureRecognizerToFail:gesture];
    }
}

[self addGestureRecognizer:tap];

I handle the taps as follows:

- (void)handleMapTap:(UITapGestureRecognizer *)tap {
    if ((tap.state & UIGestureRecognizerStateRecognized) == UIGestureRecognizerStateRecognized) {
        // Check if the overlay got tapped
        if (overlayView != nil) {
            // Get view frame rect in the mapView's coordinate system
            CGRect viewFrameInMapView = [overlayView.superview convertRect:overlayView.frame toView:self];
            // Get touch point in the mapView's coordinate system
            CGPoint point = [tap locationInView:self];

            // Check if the touch is within the view bounds
            if (CGRectContainsPoint(viewFrameInMapView, point)) {
                [overlayView handleTapAtPoint:[tap locationInView:self.directionsOverlayView]];
            }
        }
    }
}

This works as expected, now I need to check if the tap lies within the given MKPolyline overlayView (not strict, I the user taps somewhere near the polyline this should be handled as a hit).

What's a good way to do this?

- (void)handleTapAtPoint:(CGPoint)point {
    MKPolyline *polyline = self.polyline;

    // TODO: detect if point lies withing polyline with some margin
}

Thanks!

Answer

Jenson picture Jenson · Dec 6, 2013

The question is rather old, but my answer may be useful to other people looking for a solution for this problem.

This code detects touches on poly lines with a maximum distance of 22 pixels in every zoom level. Just point your UITapGestureRecognizer to handleTap:

/** Returns the distance of |pt| to |poly| in meters
 *
 * from http://paulbourke.net/geometry/pointlineplane/DistancePoint.java
 *
 */
- (double)distanceOfPoint:(MKMapPoint)pt toPoly:(MKPolyline *)poly
{
    double distance = MAXFLOAT;
    for (int n = 0; n < poly.pointCount - 1; n++) {

        MKMapPoint ptA = poly.points[n];
        MKMapPoint ptB = poly.points[n + 1];

        double xDelta = ptB.x - ptA.x;
        double yDelta = ptB.y - ptA.y;

        if (xDelta == 0.0 && yDelta == 0.0) {

            // Points must not be equal
            continue;
        }

        double u = ((pt.x - ptA.x) * xDelta + (pt.y - ptA.y) * yDelta) / (xDelta * xDelta + yDelta * yDelta);
        MKMapPoint ptClosest;
        if (u < 0.0) {

            ptClosest = ptA;
        }
        else if (u > 1.0) {

            ptClosest = ptB;
        }
        else {

            ptClosest = MKMapPointMake(ptA.x + u * xDelta, ptA.y + u * yDelta);
        }

        distance = MIN(distance, MKMetersBetweenMapPoints(ptClosest, pt));
    }

    return distance;
}


/** Converts |px| to meters at location |pt| */
- (double)metersFromPixel:(NSUInteger)px atPoint:(CGPoint)pt
{
    CGPoint ptB = CGPointMake(pt.x + px, pt.y);

    CLLocationCoordinate2D coordA = [mapView convertPoint:pt toCoordinateFromView:mapView];
    CLLocationCoordinate2D coordB = [mapView convertPoint:ptB toCoordinateFromView:mapView];

    return MKMetersBetweenMapPoints(MKMapPointForCoordinate(coordA), MKMapPointForCoordinate(coordB));
}


#define MAX_DISTANCE_PX 22.0f
- (void)handleTap:(UITapGestureRecognizer *)tap
{
    if ((tap.state & UIGestureRecognizerStateRecognized) == UIGestureRecognizerStateRecognized) {

        // Get map coordinate from touch point
        CGPoint touchPt = [tap locationInView:mapView];
        CLLocationCoordinate2D coord = [mapView convertPoint:touchPt toCoordinateFromView:mapView];

        double maxMeters = [self metersFromPixel:MAX_DISTANCE_PX atPoint:touchPt];

        float nearestDistance = MAXFLOAT;
        MKPolyline *nearestPoly = nil;

        // for every overlay ...
        for (id <MKOverlay> overlay in mapView.overlays) {

            // .. if MKPolyline ...
            if ([overlay isKindOfClass:[MKPolyline class]]) {

                // ... get the distance ...
                float distance = [self distanceOfPoint:MKMapPointForCoordinate(coord)
                                                toPoly:overlay];

                // ... and find the nearest one
                if (distance < nearestDistance) {

                    nearestDistance = distance;
                    nearestPoly = overlay;
                }
            }
        }

        if (nearestDistance <= maxMeters) {

            NSLog(@"Touched poly: %@\n"
                   "    distance: %f", nearestPoly, nearestDistance);
        }
    }
}