Reading time: 6 min

This recipe shows how to add a Map View using SwiftUI. It also shows how to add pins/markers/annotations to the map, allowing for mixing of different map annotation types.

The end result will look like this:

If you just want something you can use right away, you can find the full code here.

SwiftUI Map view is only available starting iOS 14. Unfortunately, you can't use it on iOS 13.

Let's get started!

Displaying a Map

The Map view is responsible for displaying an Apple map. Add this code to get started:

import SwiftUI
import MapKit

struct MapView: View {
  // 1
  @State private var region = MKCoordinateRegion(
      center: CLLocationCoordinate2D(latitude: 45.5473973, longitude: 18.6786983), 
      span: MKCoordinateSpan(latitudeDelta: 0.25, longitudeDelta: 0.25))

  var body: some View {
    Map(coordinateRegion: $region, // 1
            interactionModes: [.all], // 2
            showsUserLocation: true, // 3
            userTrackingMode: .constant(.follow)) // 4
  }
}

Here's a breakdown of what goes on here:

  1. You can bind the currently displayed region (MKCoordinateRegion) of your Map to a property. The region will change as the user pans around the map or zooms it.
  2. Determine how the user can interact with the map using interactionModes:. The available options are pan, zoom and all. Pass an empty array [] to prevent the user from modifying the map.
  3. Use showsUserLocation: to, well, show the user's location on the map.
  4. If you want the map to automatically move as the user moves, set userTrackingMode: to .follow. You can use a binding here to dynamically turn following on/off.

And that's it! The code above (or an even reduced version of it) is enough to display a fully functioning map view in your app.

Map Annotations

Map annotations allow you to display markers on the map. There are three predefined annotation implementation available in MapKit:

  1. MapPin displays a pin with the provided tint at the given coordinate.
  2. MapMarker is essentially a MapPin with a solid background.
  3. MapAnnotation allows you to specify a custom SwiftUI View at the provided coordinates.

Annotations source has to be an array of Identifiable, so let's add this simple struct:

struct Location: Identifiable {
  let id = UUID()
  let coordinate: CLLocationCoordinate2D
}

Then, you can add the annotations for the given source like this:

var body: some View {
  Map(coordinateRegion: $region,
      interactionModes: [.all],
      showsUserLocation: true,
      userTrackingMode: .constant(.follow)
      annotationItems: [ // ADD THIS
        Location(coordinate: CLLocationCoordinate2D(latitude: 45.5550119, longitude: 18.6786983)),
        Location(coordinate: CLLocationCoordinate2D(latitude: 45.5585655, longitude: 18.6892519))
      ]) { item in // AND THIS
        MapPin(coordinate: item.coordinate)
      }
}

Mixing different annotation types

Displaying mixed annotations is a different ballgame. Say that your goal is to show pins, markers and custom annotations on a single map, like this:

Add an enum that models these different annotation types:

enum MapMark {
  case pin(tint: Color),
       marker(tint: Color),
       price(pricing: Int, tint: Color)
}

The enum contains the two default annotation marks, the pin and the marker, as well as a custom one. The custom view in this case simply displays how pricey the location is.

Let's also extend the Location struct to hold the type:

struct Location: Identifiable {
  let id = UUID()
  let coordinate: CLLocationCoordinate2D
  let mark: MapMark // ADD THIS
}

Then, add a few locations to MapView:

private let locations = [
  Location(
      coordinate: CLLocationCoordinate2D(latitude: 45.5550119, longitude: 18.6786983), 
      mark: .pin(tint: .green)),
  Location(
      coordinate: CLLocationCoordinate2D(latitude: 45.5585655, longitude: 18.6892519), 
      mark: .marker(tint: .blue)),  
  Location(
      coordinate: CLLocationCoordinate2D(latitude: 45.5473353, longitude: 18.6890417), 
      mark: .flag(pricing: 3, tint: .orange)),
]

And add the function that displays the annotation for a Location:

private func mark(for location: Location) -> some MapAnnotationProtocol {
  switch location.mark {
  case .pin(tint: let tint):
    return MapPin(coordinate: location.coordinate, tint: tint)
  case .marker(tint: let tint):
    return MapMarker(coordinate: location.coordinate, tint: tint)
  case .flag(pricing: let pricing, tint: let tint):
    return MapAnnotation(coordinate: location.coordinate) {
      Text(String.init(repeating: "$", count: pricing))
        .font(.system(size: 12))
        .fontWeight(.semibold)
        .background(tint.cornerRadius(4))
      }
  }
}

And, lastly, alter the body a bit to consume the locations array and map them to annotations:

var body: some View {
  Map(coordinateRegion: $region,
      interactionModes: [.all],
      showsUserLocation: true,
      userTrackingMode: .constant(.follow)
      annotationItems: locations) { item in // ADD THIS
      mark(for: item)
  }
}

If you try to build now, you'll get the familiar error message on the mark function:

If you haven't, check out this recipe for more info on this type of error. As you'll learn, resolving it involves using a wrapper that erases the protocol (MapAnnotationProtocol) to a struct.

Unfortunately, for reasons unknown, there's no such wrapper for MapAnnotationProtocol in MapKit, so let's try building one ourselves!

struct AnyMapAnnotationProtocol: MapAnnotationProtocol { // 1
  let _annotationData: _MapAnnotationData // 2
  let value: Any // 3

  init<WrappedType: MapAnnotationProtocol>(_ value: WrappedType) { // 4
    self.value = value
    _annotationData = value._annotationData // 5
  }
}

What goes on is as following:

  1. The wrapper struct needs to conform to MapAnnotationProtocol.
  2. Confirming to MapAnnotationProtocol requires the presence of the field _annotationData of type _MapAnnotationData. However, notice that both of these start with an underscore, meaning that this API is private and Apple isn't ready to share it yet. I don't know why is that the case, just be mindful that this solution is a tad hacky.
  3. We hold a reference to the wrapped value, just in case.
  4. Make sure that all the values passed are of a type that conforms to MapAnnotationProtocol.
  5. This is the core of the wrapper part - copy the _annotationData from the wrapped value.

Then, just change the mark function to wrap all the return values in AnyMapAnnotationProtocol:

private func mark(for location: Location) -> some MapAnnotationProtocol {
  switch location.mark {
  case .pin(tint: let tint):
    return AnyMapAnnotationProtocol(MapPin(coordinate: location.coordinate, tint: tint))
  case .marker(tint: let tint):
    return AnyMapAnnotationProtocol(MapMarker(coordinate: location.coordinate, tint: tint))
  case .flag(pricing: let pricing, tint: let tint):
    return AnyMapAnnotationProtocol)MapAnnotation(coordinate: location.coordinate) {
      Text(String.init(repeating: "$", count: pricing))
        .font(.system(size: 12))
        .fontWeight(.semibold)
        .background(tint.cornerRadius(4))
      })
  }
}

And that's it! Everything should work as expected now. You have a basic framework for modelling annotations and you can display a heterogenous set of map annotations!

Full code for this recipe:

import SwiftUI
import MapKit

struct MapView: View {
  @State private var region = MKCoordinateRegion(
      center: CLLocationCoordinate2D(latitude: 45.5473973, longitude: 18.6786983), 
      span: MKCoordinateSpan(latitudeDelta: 0.25, longitudeDelta: 0.25))

  private let locations = [
    Location(
        coordinate: CLLocationCoordinate2D(latitude: 45.5550119, longitude: 18.6786983),
        mark: .pin(tint: .green)),
    Location(
        coordinate: CLLocationCoordinate2D(latitude: 45.5585655, longitude: 18.6892519), 
        mark: .marker(tint: .blue)),
    Location(
         coordinate: CLLocationCoordinate2D(latitude: 45.5473353, longitude: 18.6890417), 
         mark: .flag(pricing: 3, tint: .orange)),
  ]

  var body: some View {
    Map(coordinateRegion: $region,
        interactionModes: [.all],
        showsUserLocation: true,
        userTrackingMode: .constant(.follow),
        annotationItems: locations) { item in
        mark(for: item)
    }
  }

  private func mark(for location: Location) -> some MapAnnotationProtocol {
    switch location.mark {
    case .pin(tint: let tint):
      return AnyMapAnnotationProtocol(MapPin(coordinate: location.coordinate, tint: tint))
    case .marker(tint: let tint):
      return AnyMapAnnotationProtocol(MapMarker(coordinate: location.coordinate, tint: tint))
    case .flag(pricing: let pricing, tint: let tint):
      return AnyMapAnnotationProtocol(MapAnnotation(coordinate: location.coordinate) {
        Text(String.init(repeating: "$", count: pricing))
          .font(.system(size: 12))
          .fontWeight(.semibold)
          .background(tint.cornerRadius(4))
      })
    }
 }
}

private struct Location: Identifiable {
  let id = UUID()
  let coordinate: CLLocationCoordinate2D
  let mark: MapMark
}

private enum MapMark {
  case pin(tint: Color),
       marker(tint: Color),
       flag(pricing: Int, tint: Color)
}

private struct AnyMapAnnotationProtocol: MapAnnotationProtocol {
  let _annotationData: _MapAnnotationData
  let value: Any

  init<WrappedType: MapAnnotationProtocol>(_ value: WrappedType) {
    self.value = value
    _annotationData = value._annotationData
  }
}

Next Post Previous Post