Skip to content

Apple Maps polyline press hit-tests the filled shape instead of the stroke #126

Description

@jkasprzyk17

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

  • A tap inside the hollow of a U-shaped tappable polyline falls through to onPress
  • A tap on the drawn stroke of a near-straight polyline fires onPolylinePress
  • Thin polylines (strokeWidth: 1-2) are tappable within the same touch slop as a
    thick one
  • The corridor stays constant in screen points while zooming in and out
  • Polygon and circle press behaviour is unchanged
  • Verified on the iOS simulator against the Google provider for parity

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

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmedReproduced and accepted by a maintainerhelp wantedExtra attention is neededplatform: iosAffects iOSprovider: appleAffects the Apple Maps (MapKit) providerswiftThe Swift / iOS native layer (package/ios)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions