Problem
On the Apple provider, a polyline is hit-tested by filling its path, not by its stroke.
MapOverlayController.overlayId(at:) ends in a single containment check for all three
overlay kinds (MapOverlayController.swift:294):
if renderer.path?.contains(rendererPoint) == true {
return style.id
}
CGPath.contains(_:) answers "would this point be painted if the path were filled"
(nonzero winding rule by default). MKPolylineRenderer.path is an open path, and
CoreGraphics implicitly closes open subpaths when filling. So the region that counts as
"the polyline" is the polygon you get by joining the last point back to the first — and
lineWidth never enters the test at all (renderer.lineWidth is set for drawing on
MapOverlayController.swift:267, and nothing reads it back).
That produces errors in both directions:
|
Current behaviour |
Expected |
| Tap inside the hull of a curved/bent polyline |
hit — onPolylinePress fires, and onPress is swallowed |
miss |
| Tap on the visible stroke of a near-straight polyline |
miss — the implicit closure has ~no area |
hit |
Verified against CoreGraphics directly, outside MapKit, with an open 4-point U path
((0,0) → (0,100) → (100,100) → (100,0)) and an open 2-point diagonal:
== U-shaped polyline, path.contains() (current implementation) ==
U contains (50.0, 50.0): true <- dead centre of the hollow, not on the line
U contains (50.0, 10.0): true <- near the open top, not on the line
U contains (50.0, 100.0): true <- on the bottom stroke (correct, but by accident)
== straight 2-point polyline, path.contains() ==
line contains (50.0, 50.0): true <- exactly on the line, to the float
line contains (52.0, 50.0): false <- 2pt off the line
== stroked-copy hit test (proposed fix, width 44) ==
U-stroked contains (50.0, 50.0): false
U-stroked contains (50.0, 10.0): false
U-stroked contains (50.0, 100.0): true
line-stroked contains (50.0, 50.0): true
line-stroked contains (52.0, 50.0): true
line-stroked contains (200.0, 50.0): false
The straight-line row is the practical one: a tap only registers when it lands on the
mathematical line to floating-point precision, which a finger never does. A tappable
polyline that does not double back on itself is effectively untappable on Apple Maps
today, however thick its strokeWidth.
Both Google providers get this right, because neither rolls its own hit test — they hand
tappability to the SDK, which tests the stroke with a touch tolerance:
- Android —
PolylineDescriptor+PolylineOptions.kt:12 (.clickable(tappable == true)),
delivered through GoogleMapProviderAdapter.kt:494
- Google on iOS —
GoogleMapOverlayController.swift:403 (polyline.isTappable),
delivered through GoogleMapProviderAdapter.swift:560
So the same <Polyline tappable> behaves differently on all three provider/platform
combinations, and the Apple provider is the odd one out.
Reproduction
A U-shaped polyline — false positive:
<MapView
style={{ flex: 1 }}
initialRegion={{
latitude: 52.23,
longitude: 21.03,
latitudeDelta: 0.12,
longitudeDelta: 0.12,
}}
onPress={(c) => console.log('map', c)}
>
<Polyline
id="u-shape"
coordinates={[
{ latitude: 52.25, longitude: 21.0 },
{ latitude: 52.21, longitude: 21.0 },
{ latitude: 52.21, longitude: 21.06 },
{ latitude: 52.25, longitude: 21.06 },
]}
strokeColor="#007AFF"
strokeWidth={4}
tappable
onPress={(id) => console.log('polyline', id)}
/>
</MapView>
Tap the middle of the U, roughly 52.23 / 21.03 — about 2 km of empty map from the
nearest stroke, with nothing drawn under the finger.
- Apple:
polyline u-shape. onPress never fires.
- Android / Google:
map {…}. polyline u-shape only when the tap lands on the drawn line.
A near-straight polyline — false negative: run the example app's River route scenario
(example/examples/riverRoute.ts, two tappable polylines with strokeWidth: 5) and tap
the visible blue line. On Apple almost every tap falls through to onPress instead of
onPolylinePress.
Reproduced on 2026-09-17 against v1.2.1, iOS simulator, Apple provider.
Where
package/ios/MapOverlayController.swift:275-301 — overlayId(at:), the shared
path.contains test for polyline, polygon and circle
package/ios/MapOverlayController.swift:251-273 — renderer(for:), the only place
lineWidth is used
package/ios/MapOverlayController.swift:195-211 — updatePolylines, where
strokeWidth (default 4) is captured into OverlayStyle
package/ios/AppleMapProviderAdapter.swift:380-397 — notifyOverlayPress(at:)
package/ios/HybridMapViewDelegate.swift:27-53 — handleTap(_:)
Suggested fix
Split the hit test by overlay kind: stroke containment for polylines, fill containment for
polygons and circles (that keeps polygons and circles matching the Google providers, where
a fill tap is the click).
The stroke test is a stroked copy of the path:
let hitArea = path.copy(
strokingWithWidth: width,
lineCap: .round,
lineJoin: .round,
miterLimit: 1
)
return hitArea.contains(rendererPoint)
Two details decide whether this actually feels right:
1. width is in the renderer's coordinate space, not screen points. renderer.path is
built from MKMapPoints, which is why MKOverlayPathRenderer strokes with
lineWidth / zoomScale at draw time. The hit test has to apply the same scale in reverse.
Deriving it from the renderer avoids assuming anything about that space:
let origin = renderer.point(for: MKMapPoint(mapView.convert(.zero, toCoordinateFrom: mapView)))
let span = renderer.point(for: MKMapPoint(mapView.convert(CGPoint(x: 100, y: 0), toCoordinateFrom: mapView)))
let unitsPerScreenPoint = (span.x - origin.x) / 100
(MKZoomScale, i.e. mapView.bounds.width / mapView.visibleMapRect.size.width, is the
same number inverted; either is fine as long as it is measured rather than assumed.)
2. Thin lines need a minimum touch target. strokingWithWidth: centres the stroke on
the path, so the width is the full corridor: max(strokeWidth, 44) gives ±22pt around a
4pt line, matching the 44×44pt minimum touch target in the HIG. The constant is worth
tuning against the Google providers on a device rather than taking as given.
Per the repo's Swift layout rules this belongs in its own extension file rather than inside
MapOverlayController — e.g. package/ios/CGPath+StrokeContains.swift:
extension CGPath {
/// Whether `point` lies within `width` of this path's stroke, in the path's own
/// coordinate space.
func strokeContains(_ point: CGPoint, width: CGFloat) -> Bool
}
overlayId(at:) then picks strokeContains or contains on style.kind and keeps the
existing guard let path = renderer.path nil-handling.
Notes
Because the overlay branch returns before the annotation check
(HybridMapViewDelegate.swift:33 vs :37) while marker presses are delivered separately by
mapView(_:didSelect:) (HybridMapViewDelegate.swift:165-188), a marker that sits inside a
polyline's phantom fill region should fire onMarkerPress and onPolylinePress from one
tap. That follows from the code path; it was not separately measured in the audit run, and
it disappears once the phantom region does.
Acceptance criteria
Regression tests
XCTest in package/iosTests/ (wired through the podspec test spec,
package/react-native-better-maps.podspec:71; the pod already links MapKit). Keeping the
geometry in a CGPath extension means the test needs no live MKMapView:
- U-shaped open path: a point in the hollow misses, a point on the stroke hits
- Straight 2-point path: a point a few units off the line hits, a point far away misses
- Width scaling: the same tap misses at a smaller
width and hits at a larger one
Related
Problem
On the Apple provider, a polyline is hit-tested by filling its path, not by its stroke.
MapOverlayController.overlayId(at:)ends in a single containment check for all threeoverlay kinds (
MapOverlayController.swift:294):CGPath.contains(_:)answers "would this point be painted if the path were filled"(nonzero winding rule by default).
MKPolylineRenderer.pathis an open path, andCoreGraphics implicitly closes open subpaths when filling. So the region that counts as
"the polyline" is the polygon you get by joining the last point back to the first — and
lineWidthnever enters the test at all (renderer.lineWidthis set for drawing onMapOverlayController.swift:267, and nothing reads it back).That produces errors in both directions:
onPolylinePressfires, andonPressis swallowedVerified against CoreGraphics directly, outside MapKit, with an open 4-point U path
(
(0,0) → (0,100) → (100,100) → (100,0)) and an open 2-point diagonal:The straight-line row is the practical one: a tap only registers when it lands on the
mathematical line to floating-point precision, which a finger never does. A tappable
polyline that does not double back on itself is effectively untappable on Apple Maps
today, however thick its
strokeWidth.Both Google providers get this right, because neither rolls its own hit test — they hand
tappability to the SDK, which tests the stroke with a touch tolerance:
PolylineDescriptor+PolylineOptions.kt:12(.clickable(tappable == true)),delivered through
GoogleMapProviderAdapter.kt:494GoogleMapOverlayController.swift:403(polyline.isTappable),delivered through
GoogleMapProviderAdapter.swift:560So the same
<Polyline tappable>behaves differently on all three provider/platformcombinations, and the Apple provider is the odd one out.
Reproduction
A U-shaped polyline — false positive:
Tap the middle of the U, roughly
52.23 / 21.03— about 2 km of empty map from thenearest stroke, with nothing drawn under the finger.
polyline u-shape.onPressnever fires.map {…}.polyline u-shapeonly when the tap lands on the drawn line.A near-straight polyline — false negative: run the example app's River route scenario
(
example/examples/riverRoute.ts, twotappablepolylines withstrokeWidth: 5) and tapthe visible blue line. On Apple almost every tap falls through to
onPressinstead ofonPolylinePress.Reproduced on 2026-09-17 against
v1.2.1, iOS simulator, Apple provider.Where
package/ios/MapOverlayController.swift:275-301—overlayId(at:), the sharedpath.containstest for polyline, polygon and circlepackage/ios/MapOverlayController.swift:251-273—renderer(for:), the only placelineWidthis usedpackage/ios/MapOverlayController.swift:195-211—updatePolylines, wherestrokeWidth(default4) is captured intoOverlayStylepackage/ios/AppleMapProviderAdapter.swift:380-397—notifyOverlayPress(at:)package/ios/HybridMapViewDelegate.swift:27-53—handleTap(_:)Suggested fix
Split the hit test by overlay kind: stroke containment for polylines, fill containment for
polygons and circles (that keeps polygons and circles matching the Google providers, where
a fill tap is the click).
The stroke test is a stroked copy of the path:
Two details decide whether this actually feels right:
1.
widthis in the renderer's coordinate space, not screen points.renderer.pathisbuilt from
MKMapPoints, which is whyMKOverlayPathRendererstrokes withlineWidth / zoomScaleat draw time. The hit test has to apply the same scale in reverse.Deriving it from the renderer avoids assuming anything about that space:
(
MKZoomScale, i.e.mapView.bounds.width / mapView.visibleMapRect.size.width, is thesame number inverted; either is fine as long as it is measured rather than assumed.)
2. Thin lines need a minimum touch target.
strokingWithWidth:centres the stroke onthe path, so the width is the full corridor:
max(strokeWidth, 44)gives ±22pt around a4pt line, matching the 44×44pt minimum touch target in the HIG. The constant is worth
tuning against the Google providers on a device rather than taking as given.
Per the repo's Swift layout rules this belongs in its own extension file rather than inside
MapOverlayController— e.g.package/ios/CGPath+StrokeContains.swift:overlayId(at:)then picksstrokeContainsorcontainsonstyle.kindand keeps theexisting
guard let path = renderer.pathnil-handling.Notes
Because the overlay branch returns before the annotation check
(
HybridMapViewDelegate.swift:33vs:37) while marker presses are delivered separately bymapView(_:didSelect:)(HybridMapViewDelegate.swift:165-188), a marker that sits inside apolyline's phantom fill region should fire
onMarkerPressandonPolylinePressfrom onetap. That follows from the code path; it was not separately measured in the audit run, and
it disappears once the phantom region does.
Acceptance criteria
onPressonPolylinePressstrokeWidth: 1-2) are tappable within the same touch slop as athick one
Regression tests
XCTest in
package/iosTests/(wired through the podspec test spec,package/react-native-better-maps.podspec:71; the pod already links MapKit). Keeping thegeometry in a
CGPathextension means the test needs no liveMKMapView:widthand hits at a larger oneRelated
onPresswhen a tappable overlay has no handler. Thetwo fixes are independent: Apple Maps swallows onPress under a tappable overlay that has no press handler #106 is about which callback is considered "handled", this one
is about which taps count as hitting the overlay at all.