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

Viewed 10234

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!

8 Answers

@Jensemanns answer in Swift 4, which by the way was the only solution that I found that worked for me to detect clicks on a MKPolyline:

let map = MKMapView()
let mapTap = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
map.addGestureRecognizer(mapTap)

func mapTapped(_ tap: UITapGestureRecognizer) {
    if tap.state == .recognized {
        // Get map coordinate from touch point
        let touchPt: CGPoint = tap.location(in: map)
        let coord: CLLocationCoordinate2D = map.convert(touchPt, toCoordinateFrom: map)
        let maxMeters: Double = meters(fromPixel: 22, at: touchPt)
        var nearestDistance: Float = MAXFLOAT
        var nearestPoly: MKPolyline? = nil
        // for every overlay ...
        for overlay: MKOverlay in map.overlays {
            // .. if MKPolyline ...
            if (overlay is MKPolyline) {
                // ... get the distance ...
                let distance: Float = Float(distanceOf(pt: MKMapPointForCoordinate(coord), toPoly: overlay as! MKPolyline))
                // ... and find the nearest one
                if distance < nearestDistance {
                    nearestDistance = distance
                    nearestPoly = overlay as! MKPolyline
                }

            }
        }

        if Double(nearestDistance) <= maxMeters {
            print("Touched poly: \(nearestPoly) distance: \(nearestDistance)")

        }
    }
}

func distanceOf(pt: MKMapPoint, toPoly poly: MKPolyline) -> Double {
    var distance: Double = Double(MAXFLOAT)
    for n in 0..<poly.pointCount - 1 {
        let ptA = poly.points()[n]
        let ptB = poly.points()[n + 1]
        let xDelta: Double = ptB.x - ptA.x
        let yDelta: Double = ptB.y - ptA.y
        if xDelta == 0.0 && yDelta == 0.0 {
            // Points must not be equal
            continue
        }
        let u: Double = ((pt.x - ptA.x) * xDelta + (pt.y - ptA.y) * yDelta) / (xDelta * xDelta + yDelta * yDelta)
        var ptClosest: MKMapPoint
        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
}

func meters(fromPixel px: Int, at pt: CGPoint) -> Double {
    let ptB = CGPoint(x: pt.x + CGFloat(px), y: pt.y)
    let coordA: CLLocationCoordinate2D = map.convert(pt, toCoordinateFrom: map)
    let coordB: CLLocationCoordinate2D = map.convert(ptB, toCoordinateFrom: map)
    return MKMetersBetweenMapPoints(MKMapPointForCoordinate(coordA), MKMapPointForCoordinate(coordB))
}

Swift 5.x version

let map = MKMapView()
let mapTap = UITapGestureRecognizer(target: self, action: #selector(mapTapped))
map.addGestureRecognizer(mapTap)

@objc func mapTapped(_ tap: UITapGestureRecognizer) {
    if tap.state == .recognized {
        // Get map coordinate from touch point
        let touchPt: CGPoint = tap.location(in: map)
        let coord: CLLocationCoordinate2D = map.convert(touchPt, toCoordinateFrom: map)
        let maxMeters: Double = meters(fromPixel: 22, at: touchPt)
        var nearestDistance: Float = MAXFLOAT
        var nearestPoly: MKPolyline? = nil
        // for every overlay ...
        for overlay: MKOverlay in map.overlays {
            // .. if MKPolyline ...
            if (overlay is MKPolyline) {
                // ... get the distance ...
                let distance: Float = Float(distanceOf(pt: MKMapPoint(coord), toPoly: overlay as! MKPolyline))
                // ... and find the nearest one
                if distance < nearestDistance {
                    nearestDistance = distance
                    nearestPoly = overlay as? MKPolyline
                }

            }
        }

        if Double(nearestDistance) <= maxMeters {
            print("Touched poly: \(String(describing: nearestPoly)) distance: \(nearestDistance)")

        }
    }
}

private func distanceOf(pt: MKMapPoint, toPoly poly: MKPolyline) -> Double {
    var distance: Double = Double(MAXFLOAT)
    for n in 0..<poly.pointCount - 1 {
        let ptA = poly.points()[n]
        let ptB = poly.points()[n + 1]
        let xDelta: Double = ptB.x - ptA.x
        let yDelta: Double = ptB.y - ptA.y
        if xDelta == 0.0 && yDelta == 0.0 {
            // Points must not be equal
            continue
        }
        let u: Double = ((pt.x - ptA.x) * xDelta + (pt.y - ptA.y) * yDelta) / (xDelta * xDelta + yDelta * yDelta)
        var ptClosest: MKMapPoint
        if u < 0.0 {
            ptClosest = ptA
        }
        else if u > 1.0 {
            ptClosest = ptB
        }
        else {
            ptClosest = MKMapPoint(x: ptA.x + u * xDelta, y: ptA.y + u * yDelta)
        }

        distance = min(distance, ptClosest.distance(to: pt))
    }
    return distance
}

private func meters(fromPixel px: Int, at pt: CGPoint) -> Double {
    let ptB = CGPoint(x: pt.x + CGFloat(px), y: pt.y)
    let coordA: CLLocationCoordinate2D = map.convert(pt, toCoordinateFrom: map)
    let coordB: CLLocationCoordinate2D = map.convert(ptB, toCoordinateFrom: map)
    return MKMapPoint(coordA).distance(to: MKMapPoint(coordB))
}

@Rashwan L : Updated his answer to Swift 4.2

let map = MKMapView()
let mapTap = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
 map.addGestureRecognizer(mapTap)

 @objc private func mapTapped(_ tap: UITapGestureRecognizer) {
    if tap.state == .recognized && tap.state == .recognized {
        // Get map coordinate from touch point
        let touchPt: CGPoint = tap.location(in: skyMap)
        let coord: CLLocationCoordinate2D = skyMap.convert(touchPt, toCoordinateFrom: skyMap)
        let maxMeters: Double = meters(fromPixel: 22, at: touchPt)
        var nearestDistance: Float = MAXFLOAT
        var nearestPoly: MKPolyline? = nil
        // for every overlay ...
        for overlay: MKOverlay in skyMap.overlays {
            // .. if MKPolyline ...
            if (overlay is MKPolyline) {
                // ... get the distance ...
                let distance: Float = Float(distanceOf(pt: MKMapPoint(coord), toPoly: overlay as! MKPolyline))
                // ... and find the nearest one
                if distance < nearestDistance {
                    nearestDistance = distance
                    nearestPoly = overlay as? MKPolyline
                }

            }
        }

        if Double(nearestDistance) <= maxMeters {
            print("Touched poly: \(nearestPoly) distance: \(nearestDistance)")

        }
    }
}

private func distanceOf(pt: MKMapPoint, toPoly poly: MKPolyline) -> Double {
    var distance: Double = Double(MAXFLOAT)
    for n in 0..<poly.pointCount - 1 {
        let ptA = poly.points()[n]
        let ptB = poly.points()[n + 1]
        let xDelta: Double = ptB.x - ptA.x
        let yDelta: Double = ptB.y - ptA.y
        if xDelta == 0.0 && yDelta == 0.0 {
            // Points must not be equal
            continue
        }
        let u: Double = ((pt.x - ptA.x) * xDelta + (pt.y - ptA.y) * yDelta) / (xDelta * xDelta + yDelta * yDelta)
        var ptClosest: MKMapPoint
        if u < 0.0 {
            ptClosest = ptA
        }
        else if u > 1.0 {
            ptClosest = ptB
        }
        else {
            ptClosest = MKMapPoint(x: ptA.x + u * xDelta, y: ptA.y + u * yDelta)
        }

        distance = min(distance, ptClosest.distance(to: pt))
    }
    return distance
}

private func meters(fromPixel px: Int, at pt: CGPoint) -> Double {
    let ptB = CGPoint(x: pt.x + CGFloat(px), y: pt.y)
    let coordA: CLLocationCoordinate2D = skyMap.convert(pt, toCoordinateFrom: skyMap)
    let coordB: CLLocationCoordinate2D = skyMap.convert(ptB, toCoordinateFrom: skyMap)
    return MKMapPoint(coordA).distance(to: MKMapPoint(coordB))
}

The real "cookie" in this code is the point -> line distance function. I was so happy to find it and it worked great (swift 4, iOS 11). Thanks to everyone, especially @Jensemann. Here is my refactoring of it:

public extension MKPolyline {

    // Return the point on the polyline that is the closest to the given point
    // along with the distance between that closest point and the given point.
    //
    // Thanks to:
    // http://paulbourke.net/geometry/pointlineplane/
    // https://stackoverflow.com/questions/11713788/how-to-detect-taps-on-mkpolylines-overlays-like-maps-app

    public func closestPoint(to: MKMapPoint) -> (point: MKMapPoint, distance: CLLocationDistance) {

        var closestPoint = MKMapPoint()
        var distanceTo = CLLocationDistance.infinity

        let points = self.points()
        for i in 0 ..< pointCount - 1 {
            let endPointA = points[i]
            let endPointB = points[i + 1]

            let deltaX: Double = endPointB.x - endPointA.x
            let deltaY: Double = endPointB.y - endPointA.y
            if deltaX == 0.0 && deltaY == 0.0 { continue } // Points must not be equal

            let u: Double = ((to.x - endPointA.x) * deltaX + (to.y - endPointA.y) * deltaY) / (deltaX * deltaX + deltaY * deltaY) // The magic sauce. See the Paul Bourke link above.

            let closest: MKMapPoint
            if u < 0.0 { closest = endPointA }
            else if u > 1.0 { closest = endPointB }
            else { closest = MKMapPointMake(endPointA.x + u * deltaX, endPointA.y + u * deltaY) }

            let distance = MKMetersBetweenMapPoints(closest, to)
            if distance < distanceTo {
                closestPoint = closest
                distanceTo = distance
            }
        }

        return (closestPoint, distanceTo)
    }
}

It's an old thread however I found a different way which may help anyone. Tested on multiple routes overlay in Swift 4.2.

 @IBAction func didTapGesture(_ sender: UITapGestureRecognizer) {
        let touchPoint = sender.location(in: mapView)
        let touchCoordinate = mapView.convert(touchPoint, toCoordinateFrom: mapView)
        let mapPoint = MKMapPoint(touchCoordinate)

        for overlay in mapView.overlays {
            if overlay is MKPolyline {
                if let polylineRenderer = mapView.renderer(for: overlay) as? MKPolylineRenderer {
                    let polylinePoint = polylineRenderer.point(for: mapPoint)

                    if polylineRenderer.path.contains(polylinePoint) {
                        print("polyline was tapped")
                    }
                }
            }
        }
 }
Related