MapKit for SwiftUI: Maps, Markers, Routing, and Custom Styles
Drop a pin on a map. It sounds simple — until you try it with the pre-iOS 17 Map view and realize you are fighting
MKCoordinateRegion bindings, wrapping MKMapView in UIViewRepresentable, and manually converting annotation models.
iOS 17 rewrote the MapKit SwiftUI API from scratch, and the result is the most expressive map API Apple has ever
shipped.
This guide covers the new Map initializer, Marker and Annotation content builders, MapCameraPosition for
programmatic camera control, MapStyle for visual customization, MapPolyline for drawing routes, and integrating
MKDirections for turn-by-turn routing. We will not cover MKLocalSearch or the Look Around API — those deserve their
own posts.
Contents
- The Problem
- The New Map View
- Markers and Annotations
- Camera Control with MapCameraPosition
- Map Styles
- Drawing Routes with MapPolyline
- Routing with MKDirections
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Before iOS 17, placing custom content on a SwiftUI map required either the limited
Map(coordinateRegion:annotationItems:) initializer or dropping down to UIViewRepresentable:
// ❌ Pre-iOS 17: limited annotation support, no polylines, no camera control
struct OldPixarStudioMap: View {
@State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.8323, longitude: -122.2864),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
let studios: [PixarStudio]
var body: some View {
Map(coordinateRegion: $region, annotationItems: studios) { studio in
MapAnnotation(coordinate: studio.coordinate) {
// Custom view — but no system tint, no clustering, no selection
Image(systemName: "film")
.foregroundColor(.blue)
}
}
// No way to draw a route polyline
// No way to animate to a camera position
// No way to change the map style
}
}
This API could not draw polylines, did not support programmatic camera animation, had no map style options, and its
two-way MKCoordinateRegion binding was the source of well-documented SwiftUI state loop bugs. iOS 17 replaced it
entirely.
The New Map View
The iOS 17 Map view uses a content builder — the same
@MapContentBuilder pattern you know from SwiftUI’s @ViewBuilder. Instead of passing an annotation array, you declare
map content declaratively:
import MapKit
import SwiftUI
@available(iOS 17.0, *)
struct PixarStudioMap: View {
var body: some View {
Map {
Marker(
"Pixar Animation Studios",
coordinate: CLLocationCoordinate2D(
latitude: 37.8323,
longitude: -122.2864
)
)
.tint(.blue)
}
}
}
That is a fully functional map with a tinted marker — no bindings, no region management, no boilerplate. The Map view
manages its own camera state by default, starting centered on the content you provide.
Markers and Annotations
MapKit for SwiftUI distinguishes between two content types:
Marker for system-styled pins and
Annotation for fully custom SwiftUI views.
Markers
Markers are the standard balloon pins. They support a title, system image, monogram, and tint color:
@available(iOS 17.0, *)
struct PixarLocationsMap: View {
let locations: [PixarLocation]
var body: some View {
Map {
ForEach(locations) { location in
Marker(
location.name,
systemImage: location.iconName,
coordinate: location.coordinate
)
.tint(location.studioColor)
}
}
}
}
struct PixarLocation: Identifiable {
let id = UUID()
let name: String
let iconName: String
let coordinate: CLLocationCoordinate2D
let studioColor: Color
}
You can use Marker(_:monogram:coordinate:) to show a two-letter abbreviation instead of an icon — useful when you have
more locations than distinct SF Symbols.
Annotations
When a standard marker is not enough, Annotation lets you provide an arbitrary SwiftUI view anchored to a coordinate:
@available(iOS 17.0, *)
struct ToyStoryLocationMap: View {
var body: some View {
Map {
Annotation(
"Andy's House",
coordinate: CLLocationCoordinate2D(
latitude: 34.0522, longitude: -118.2437
)
) {
VStack(spacing: 4) {
Image(systemName: "house.fill")
.font(.title2)
.foregroundStyle(.white)
.padding(8)
.background(.orange, in: Circle())
Text("Andy's House")
.font(.caption2)
.bold()
}
}
Annotation(
"Pizza Planet",
coordinate: CLLocationCoordinate2D(
latitude: 34.0195, longitude: -118.4912
)
) {
VStack(spacing: 4) {
Image(systemName: "fork.knife")
.font(.title2)
.foregroundStyle(.white)
.padding(8)
.background(.green, in: Circle())
Text("Pizza Planet")
.font(.caption2)
.bold()
}
}
}
}
}
Tip: Annotations are full SwiftUI views, which means they participate in animation, accessibility, and dynamic type. But they are more expensive to render than Markers. Use Markers for large datasets (hundreds of pins) and Annotations only where custom visuals are essential.
Selection
The new Map supports built-in selection. Bind a Marker or Annotation tag to a selection state:
@available(iOS 17.0, *)
struct SelectableStudioMap: View {
let studios: [PixarLocation]
@State private var selectedStudio: PixarLocation.ID?
var body: some View {
Map(selection: $selectedStudio) {
ForEach(studios) { studio in
Marker(studio.name, coordinate: studio.coordinate)
.tag(studio.id)
}
}
.onChange(of: selectedStudio) { _, newValue in
if let selected = studios.first(where: { $0.id == newValue }) {
print("Selected: \(selected.name)")
}
}
}
}
When the user taps a marker, selectedStudio updates automatically. No delegate callbacks, no gesture recognizers.
Camera Control with MapCameraPosition
MapCameraPosition replaces the old
MKCoordinateRegion binding. It is an enum with cases for common positioning strategies:
@available(iOS 17.0, *)
struct CameraControlDemo: View {
@State private var cameraPosition: MapCameraPosition = .automatic
var body: some View {
VStack {
Map(position: $cameraPosition) {
Marker("Pixar HQ", coordinate: pixarHQ)
Marker("Walt Disney Studios", coordinate: disneyStudios)
}
HStack {
Button("Pixar") {
withAnimation(.easeInOut(duration: 1.0)) {
cameraPosition = .region(MKCoordinateRegion(
center: pixarHQ,
latitudinalMeters: 1000,
longitudinalMeters: 1000
))
}
}
Button("Disney") {
withAnimation(.easeInOut(duration: 1.0)) {
cameraPosition = .camera(MapCamera(
centerCoordinate: disneyStudios,
distance: 2000,
heading: 45,
pitch: 60
))
}
}
Button("Show All") {
withAnimation {
cameraPosition = .automatic
}
}
}
.buttonStyle(.bordered)
}
}
private let pixarHQ = CLLocationCoordinate2D(
latitude: 37.8323, longitude: -122.2864
)
private let disneyStudios = CLLocationCoordinate2D(
latitude: 34.1561, longitude: -118.3254
)
}
The key MapCameraPosition cases:
.automatic— Fits all map content in the visible area. The default..region(MKCoordinateRegion)— Centers on a specific region with a span..camera(MapCamera)— Full control over center, distance, heading, and pitch for 3D perspectives..userLocation(fallback:)— Tracks the user’s location with a fallback position..item(MKMapItem)— Centers on a specific map item.
Note: Camera animations use standard SwiftUI
withAnimation— wrap thecameraPositionassignment in an animation block and MapKit handles the flyover transition.
Reacting to Camera Changes
Use the onMapCameraChange modifier to observe when the user pans, zooms, or rotates the map:
@available(iOS 17.0, *)
struct ObservableCameraMap: View {
@State private var cameraPosition: MapCameraPosition = .automatic
@State private var visibleRegion: MKCoordinateRegion?
var body: some View {
Map(position: $cameraPosition) {
ForEach(studios) { studio in
Marker(studio.name, coordinate: studio.coordinate)
}
}
.onMapCameraChange(frequency: .onEnd) { context in
visibleRegion = context.region
// Use visibleRegion to fetch nearby POIs or filter data
}
}
}
The frequency parameter can be .onEnd (fires once after the camera settles) or .continuous (fires during gesture —
use sparingly as it impacts performance).
Map Styles
MapStyle controls the visual appearance of the map. Apply
it with the .mapStyle(_:) modifier:
@available(iOS 17.0, *)
struct StyledPixarMap: View {
@State private var selectedStyle: MapStyleOption = .standard
var body: some View {
Map {
Marker("Pixar HQ", coordinate: pixarHQ)
}
.mapStyle(selectedStyle.style)
}
private let pixarHQ = CLLocationCoordinate2D(
latitude: 37.8323, longitude: -122.2864
)
}
enum MapStyleOption: String, CaseIterable {
case standard, hybrid, imagery
var style: MapStyle {
switch self {
case .standard:
return .standard(
elevation: .realistic,
pointsOfInterest: .including([.restaurant, .movieTheater])
)
case .hybrid:
return .hybrid(elevation: .realistic)
case .imagery:
return .imagery(elevation: .realistic)
}
}
}
The .standard style accepts a pointsOfInterest parameter that filters which POI categories appear on the map. For a
Pixar location finder, you might include only .movieTheater, .museum, and .amusementPark to keep the map focused.
Note:
MapStyle.imagery(satellite view) does not support points-of-interest overlays. Switching to imagery silently drops any POI configuration.
Drawing Routes with MapPolyline
MapPolyline draws a path on the map. You provide an
array of coordinates or an MKRoute:
@available(iOS 17.0, *)
struct PixarStudioTourRoute: View {
let tourStops: [CLLocationCoordinate2D]
var body: some View {
Map {
// Draw the route
MapPolyline(coordinates: tourStops)
.stroke(.blue, lineWidth: 4)
// Mark each stop
ForEach(Array(tourStops.enumerated()), id: \.offset) { index, coordinate in
Marker("Stop \(index + 1)", coordinate: coordinate)
.tint(.orange)
}
}
}
}
MapPolyline also accepts an MKRoute directly, which is the natural output of MKDirections — see the next section.
Routing with MKDirections
To draw a real driving or walking route between two points, combine
MKDirections with MapPolyline. Here is a complete
example that calculates a route between two Pixar-themed landmarks:
import MapKit
import SwiftUI
@available(iOS 17.0, *)
struct PixarRoadTripView: View {
@State private var route: MKRoute?
@State private var cameraPosition: MapCameraPosition = .automatic
@State private var travelTime: String?
@State private var isCalculating = false
private let origin = CLLocationCoordinate2D(
latitude: 37.8323, longitude: -122.2864
)
private let destination = CLLocationCoordinate2D(
latitude: 34.1561, longitude: -118.3254
)
var body: some View {
VStack(spacing: 0) {
Map(position: $cameraPosition) {
Marker("Pixar HQ", systemImage: "building.2", coordinate: origin)
.tint(.blue)
Marker("Disney Studios", systemImage: "sparkles", coordinate: destination)
.tint(.purple)
if let route {
MapPolyline(route.polyline)
.stroke(.blue, lineWidth: 5)
}
}
.mapStyle(.standard(elevation: .realistic))
if let travelTime {
Text("Estimated travel time: \(travelTime)")
.font(.subheadline)
.padding()
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
}
}
.overlay(alignment: .topTrailing) {
if isCalculating {
ProgressView()
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
.padding()
}
}
.task { await calculateRoute() }
}
private func calculateRoute() async {
isCalculating = true
defer { isCalculating = false }
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: origin))
request.destination = MKMapItem(
placemark: MKPlacemark(coordinate: destination)
)
request.transportType = .automobile
do {
let directions = MKDirections(request: request)
let response = try await directions.calculate()
if let primaryRoute = response.routes.first {
route = primaryRoute
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
formatter.allowedUnits = [.hour, .minute]
travelTime = formatter.string(
from: primaryRoute.expectedTravelTime
)
withAnimation(.easeInOut(duration: 1.5)) {
cameraPosition = .automatic
}
}
} catch {
print("Route calculation failed: \(error.localizedDescription)")
}
}
}
MKDirections.calculate() is async as of iOS 16 — no completion handler required. The response contains an array of
MKRoute objects, each with a polyline property that MapPolyline accepts directly.
Warning:
MKDirectionsrequests count against your Apple Maps Server API quota. In production, cache routes when the origin and destination have not changed, and debounce recalculations when the user adjusts a draggable pin.
Advanced Usage
Map Interaction Modes
You can restrict how users interact with the map using the .mapControls modifier and MapInteractionModes:
@available(iOS 17.0, *)
struct RestrictedInteractionMap: View {
var body: some View {
Map(interactionModes: [.pan, .zoom]) {
// Pitch and rotate disabled — useful for 2D-only map UIs
Marker("Monsters, Inc.", coordinate: monstersIncHQ)
}
.mapControls {
MapCompass()
MapScaleView()
MapUserLocationButton()
}
}
private let monstersIncHQ = CLLocationCoordinate2D(
latitude: 37.7749, longitude: -122.4194
)
}
The .mapControls builder lets you choose which system controls appear. By default, all controls are shown. Passing an
empty builder hides them all — useful for decorative maps or backgrounds.
User Location Tracking
To display and track the user’s location, use MapCameraPosition.userLocation(fallback:) combined with
UserAnnotation:
@available(iOS 17.0, *)
struct UserTrackingMap: View {
@State private var cameraPosition: MapCameraPosition = .userLocation(
fallback: .automatic
)
var body: some View {
Map(position: $cameraPosition) {
UserAnnotation()
// Other content
Marker("Nemo's Reef", coordinate: nemoReef)
}
.mapControls {
MapUserLocationButton()
MapCompass()
}
}
private let nemoReef = CLLocationCoordinate2D(
latitude: -16.4827, longitude: 145.4631
)
}
Note: User location requires the
NSLocationWhenInUseUsageDescriptionkey in yourInfo.plistand a corresponding CLLocationManager authorization request. The map will not show the user dot without location permission.
MapReader for Coordinate Conversion
MapReader provides a MapProxy that converts between
screen points and map coordinates — essential for “tap to place a pin” features:
@available(iOS 17.0, *)
struct TapToPlacePinMap: View {
@State private var pins: [CLLocationCoordinate2D] = []
var body: some View {
MapReader { proxy in
Map {
ForEach(Array(pins.enumerated()), id: \.offset) { index, coordinate in
Marker("Pin \(index + 1)", coordinate: coordinate)
.tint(.red)
}
}
.onTapGesture { screenPoint in
if let coordinate = proxy.convert(screenPoint, from: .local) {
pins.append(coordinate)
}
}
}
}
}
Performance Considerations
MapKit’s SwiftUI layer is backed by the same MKMapView rendering engine. The SwiftUI wrapper adds a thin overhead for
diffing map content, but the dominant costs are tile loading and annotation rendering:
- Marker vs. Annotation —
Markeris GPU-rendered by the map engine.Annotationcreates a UIKit host for each SwiftUI view. For datasets larger than 50 points, preferMarkerand switch toAnnotationonly for the selected item. - MapPolyline rendering — Polylines with thousands of coordinates render efficiently because MapKit simplifies the geometry at each zoom level. You do not need to pre-simplify coordinate arrays.
- Camera animation — The
.easeInOutanimations onMapCameraPositionrun on the render server, not the main thread. They perform well even during heavy view updates. - MKDirections API quota — Apple does not publish exact rate limits, but aggressive polling (recalculating routes on every map pan) will result in throttled responses. Cache routes and recalculate only on meaningful user actions.
- Map content diffing — The
@MapContentBuilderdiffs content by identity. Ensure yourForEachdata conforms toIdentifiablewith stable IDs. Using array indices as IDs causes the entire map content to reload on every data change.
Apple Docs:
Map— MapKit for SwiftUI
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Interactive map with pins and user location | Map with Marker/Annotation — the primary use case |
| Turn-by-turn navigation UI | MKDirections for route calculation, but consider CarPlay APIs for full navigation |
| Static decorative map (e.g., contact page) | Map with restricted interactionModes and no controls |
| Hundreds of clustered data points | Use Marker (not Annotation) and implement server-side clustering for 1000+ points |
| Map in a UIKit-based app | MKMapView directly — wrapping SwiftUI Map in UIHostingController adds overhead |
| Fully custom map tiles or non-Apple base maps | Mapbox GL or Google Maps SDK — MapKit does not support custom tile servers |
Summary
- iOS 17’s
Mapview uses a@MapContentBuilderfor declarative, composable map content —Markerfor system-styled pins,Annotationfor custom SwiftUI views. MapCameraPositionreplaces the fragileMKCoordinateRegionbinding with type-safe cases for.automatic,.region,.camera,.userLocation, and.itempositioning, all animatable with standard SwiftUI animations.MapStyleprovides.standard,.hybrid, and.imagerystyles with options for elevation, emphasis, and point-of-interest filtering.MapPolylinedraws routes and paths, accepting either raw coordinates or anMKRoutefromMKDirections.calculate().MapReaderwithMapProxyconverts between screen points and map coordinates for tap-to-place-pin interactions.
For the location data that powers your map, see Core Location Modern API: Async Updates and Geofencing. To build a complete location-based app from scratch, check out Build a Maps App with MapKit.