---
title: "MapKit Overlays: Polylines, Polygons, and Tile Layers"
slug: "mapkit-overlays"
category: "ios"
tags: ["ios", "mapkit", "swift", "overlays", "polyline", "polygon", "tile-overlay", "swiftui"]
status: "stable"
last_updated: 2026-08-14
summary: "Drawing routes, zones, and custom tile layers on a MapKit map using MapPolyline, MapPolygon, MKMapOverlayRenderer, and MKTileOverlay."
related: ["[[ios/mapkit]]", "[[ios/mapkit-annotations]]", "[[ios/mapkit-camera]]", "[[ios/swiftui]]", "[[coding/swift]]", "[[ios/core-data]]"]
---

## 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 [[ios/mapkit-annotations]]; for camera framing of overlays, see [[ios/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.

```swift
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 [[ios/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.

```swift
@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`.

```swift
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.

```swift
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.

```swift
mapView.addOverlay(routePolyline, level: .aboveRoads)
mapView.addOverlay(zonePoly, level: .aboveRoads)
mapView.addOverlay(heatmapTile, level: .aboveLabels)
```

In the SwiftUI API, `.mapOverlayLevel(level: .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 [[ios/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.

```swift
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.

## Related

- [[ios/mapkit]]
- [[ios/mapkit-annotations]]
- [[ios/mapkit-camera]]
- [[ios/mapkit-search]]
- [[ios/swiftui]]
- [[coding/swift]]
- [[ios/core-data]]
