Overview
Overlays communicate geometry rather than points: routes, zones, heatmap tiles, and administrative boundaries all live in overlay space. MapKit for SwiftUI (iOS 17+) exposes MapPolyline and MapPolygon as declarative view builder items. For tile layers or rendering control beyond what the SwiftUI API provides, MKTileOverlay and MKMapOverlayRenderer remain the right primitives accessed through an MKMapView wrapper. For point markers, see mapkit-annotations; for camera framing of overlays, see mapkit-camera.
Use MapPolyline and MapPolygon for declarative overlay drawing
On iOS 17+, place overlay items directly inside the Map view builder. No MKOverlayRenderer subclass or delegate is required.
Map {
MapPolyline(coordinates: route.coordinates)
.stroke(.blue, style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round))
MapPolygon(coordinates: zone.boundary)
.foregroundStyle(.green.opacity(0.15))
.stroke(.green, lineWidth: 2)
}Store coordinate arrays in the model, not in a computed property inside body. Recomputing a large array of CLLocationCoordinate2D on every render re-uploads geometry unnecessarily.
Store coordinates in the model layer, not in view state
A route of 10,000 points should live in a repository or core-data fetch result, not in a @State array computed from scratch when the view appears. Keep the array stable; MapKit diffs the overlay content when the ForEach id changes.
@Observable final class RouteModel {
var segments: [RouteSegment] = []
}
struct RoutesLayer: View {
let model: RouteModel
var body: some View {
ForEach(model.segments) { segment in
MapPolyline(coordinates: segment.coordinates)
.stroke(segment.color, lineWidth: 3)
}
}
}Embed RoutesLayer inside the Map builder. Extracting sub-views from the map content builder keeps body readable without breaking the rendering pipeline.
Fall back to MKMapView + MKMapOverlayRenderer for advanced stroke control
MapPolyline supports StrokeStyle and gradient strokes. When you need live animated dashes, a custom CALayer-backed renderer, or a renderer that draws differently per zoom level, subclass MKPolylineRenderer and return it from the MKMapViewDelegate.
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
guard let polyline = overlay as? MKPolyline else {
return MKOverlayRenderer(overlay: overlay)
}
let renderer = MKPolylineRenderer(polyline: polyline)
renderer.strokeColor = UIColor.systemBlue
renderer.lineWidth = 4
renderer.lineDashPattern = [8, 4]
return renderer
}Keep custom renderers stateless. If you need to animate them, use setNeedsDisplay() called from a CADisplayLink and derive the visual from the current timestamp inside draw(_:zoomScale:in:).
Use MKTileOverlay for raster tile layers
Custom tile servers (heatmaps, historical imagery, terrain) plug in as MKTileOverlay. Provide a URL template and optionally cache responses.
let overlay = MKTileOverlay(urlTemplate: "https://tiles.example.com/{z}/{x}/{y}.png")
overlay.canReplaceMapContent = false // keep base map visible beneath
mapView.addOverlay(overlay, level: .aboveLabels)Return MKTileOverlayRenderer from the delegate. Set canReplaceMapContent = true only when the tile layer is opaque and replaces the entire base map (satellite alternatives, custom basemaps).
Insert overlays at the correct z-level with MKOverlayLevel
Overlays render at .aboveRoads (below labels and buildings) or .aboveLabels (on top of everything). Route lines belong at .aboveRoads; semi-transparent zone polygons that should not obscure street names also belong there.
mapView.addOverlay(routePolyline, level: .aboveRoads)
mapView.addOverlay(zonePoly, level: .aboveRoads)
mapView.addOverlay(heatmapTile, level: .aboveLabels)In the SwiftUI API, .mapOverlayLevel(.aboveRoads) on a MapPolygon achieves the same result without a delegate.
Clip large polygon point counts before rendering
MapKit renders every vertex. A polygon with 50,000 points derived from raw GeoJSON will stutter on older devices. Simplify geometry before storing it.
- Use the Ramer-Douglas-Peucker algorithm with a tolerance matched to your minimum zoom level.
- Target fewer than 2,000 vertices for interactive polygons; tile overlays are exempt because the server handles simplification per zoom.
- Run simplification once at import time and persist the simplified coordinates in core-data.
Respond to overlay taps via MapProxy or gesture recognizers
The SwiftUI Map does not forward tap callbacks for overlays. Use MapReader to convert a tap location to a coordinate, then test containment manually.
MapReader { proxy in
Map { /* overlays */ }
.onTapGesture { screenPoint in
guard let coord = proxy.convert(screenPoint, from: .local) else { return }
if let hit = zones.first(where: { $0.polygon.contains(coord) }) {
selectedZone = hit
}
}
}For MKMapView-based code, attach a UITapGestureRecognizer to the map view and call mapView.overlays(at:) from the recognizer action.