TEAM LICENSES: Save money and learn new skills through a Hacking with Swift+ team license >>

Integrating MapKit with SwiftUI

Paul Hudson    @twostraws   

Maps have been a core feature of iPhone since the very first device shipped way back in 2007, and the underlying framework has been available to developers for almost as long. Even better, Apple provides a SwiftUI Map view that wraps up the underlying map framework beautifully, letting us place maps, annotations, and more alongside the rest of our SwiftUI view hierarchy.

Let’s start with something simple: just showing a map. Maps and all their configuration data come from a dedicated framework called MapKit, so our first step is to import that framework:

import MapKit 

And now we can place a map in our SwiftUI view, with just this:

Map()

That's enough to show a map on the screen, so try running the app and take a moment to learn some key shortcuts in the simulator:

  • Hold down the Option key to trigger two-finger pinching. If you click and drag while option is held down, the virtual fingers will move closer or further.
  • Hold down Option and Shift to trigger two-finger panning. If you click and drag up and down while this combination is held down, you'll adjust the tilt of the map.
  • You can also mimic the single-finger zoom by tapping once, then tapping and dragging up or down.

Once you're in control of the map, there are stacks of options to customize it further.

For example, you can use the mapStyle() modifier to control how the map looks. You can get a satellite map like this:

Map()
    .mapStyle(.imagery)

Or combine both satellite and street map like this:

Map()
    .mapStyle(.hybrid)

Or you can get both maps along with realistic elevation, creating a 3D map, like this:

.mapStyle(.hybrid(elevation: .realistic))

You can adjust how the user can work with your map, such as whether they can zoom or rotate their position. For example, we could make a map that always remains centered on a particular location, but users can still adjust the rotation and zoom:

Map(interactionModes: [.rotate, .zoom])

Or we could specify no interaction modes, meaning that the map is always exactly fixed:

Map(interactionModes: [])

Those are all the easy customization options, but there are three that take a little more thinking: controlling the position, placing annotations, and handling taps.

First, you can customize the position of the camera. This can be done as an initial position, where you're setting how the map should start, or as a binding to its current position, which tracks its position over time.

For example, we could create a constant property storing the location of London, with a span specified as 1 degree by 1 degree:

let position = MapCameraPosition.region(
    MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275),
        span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
    )
)

We could then use that for the initial position of our map:

Map(initialPosition: position)

That value is only an initial position. If you want to change the position over time you'll need to mark it as @State then pass it in as a binding.

So, first make it use @State:

@State private var position = MapCameraPosition.region(
    MKCoordinateRegion(
        center: CLLocationCoordinate2D(latitude: 51.507222, longitude: -0.1275),
        span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
    )
)

Then pass it in as a binding:

Map(position: $position)

Now that's it's stored as program state, we can change it by adding some buttons to jump to other locations. For example, we could wrap the map in a VStack, then place this below it:

HStack(spacing: 50) {
    Button("Paris") {
        position = MapCameraPosition.region(
            MKCoordinateRegion(
                center: CLLocationCoordinate2D(latitude: 48.8566, longitude: 2.3522),
                span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
            )
        )
    }

    Button("Tokyo") {
        position = MapCameraPosition.region(
            MKCoordinateRegion(
                center: CLLocationCoordinate2D(latitude: 35.6897, longitude: 139.6922),
                span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
            )
        )
    }
}

Although we're now passing a binding to the map, we can't just read the location as the map moves around. Instead, we have a separate onMapCameraChange() modifier that tells us when the position changes, either immediately or once movement has ended.

For example, we could write get an update when they have finished dragging the map, then print it out:

Map(position: $position)
    .onMapCameraChange { context in
        print(context.region)
    }

Alternatively, you can have it post continuous updates like this:

Map(position: $position)
    .onMapCameraChange(frequency: .continuous) { context in
        print(context.region)
    }

You might think continuous mode is always preferable, but it's not that simple – if you're running a search on where the user has positioned the map, that's the kind of thing you'd want to do only when they have finished moving.

The second customizable thing I want to look at is placing annotations.

To do this takes at least three steps depending on your goal: defining a new data type that contains your location, creating an array of those containing all your locations, then adding them as annotations in the map. Whatever new data type you create to store locations, it must conform to the Identifiable protocol so that SwiftUI can identify each map marker uniquely.

For example, we might start with this kind of Location struct:

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

Now we can go ahead and define an array of locations, wherever we want map annotations to appear:

let locations = [
    Location(name: "Buckingham Palace", coordinate: CLLocationCoordinate2D(latitude: 51.501, longitude: -0.141)),
    Location(name: "Tower of London", coordinate: CLLocationCoordinate2D(latitude: 51.508, longitude: -0.076))
]

Step three is the important part: we can feed that array of locations into the Map view as its content. SwiftUI provides us with a couple of different content types, but a simple one is called Marker: a balloon with a title and latitude/longitude coordinate attached.

For example, we could place markers at both our locations like so:

Map {
    ForEach(locations) { location in
        Marker(location.name, coordinate: location.coordinate)
    }
}

When that runs you’ll see two red balloons on the map, and even better you'll see the map adjusts its position and scale so the two markers are visible.

If you want more control over the way your markers look on the map, use an Annotation instead. This lets you provide a completely custom view to use instead of the standard system marker balloon, and if you prefer you can hide the default title so you can replace it with your own, like this:

Annotation(location.name, coordinate: location.coordinate) {
    Text(location.name)
        .font(.headline)
        .padding()
        .background(.blue)
        .foregroundStyle(.white)
        .clipShape(.capsule)
}
.annotationTitles(.hidden)

And finally, you can handle taps on the map using onTapGesture(). This tells us where on the map the user tapped, but it does so in screen coordinates – e.g., 50 points from the top, and 100 points from the left.

In order to get an actual location on the map, we need a special view called MapReader. When you wrap one of these around your map, you'll be handed a special MapProxy object that is able to convert screen locations to map locations and back the other way.

Use it like this:

MapReader { proxy in
    Map()
        .onTapGesture { position in
            if let coordinate = proxy.convert(position, from: .local) {
                print(coordinate)
            }
        }
}

Tip: The .local part means we're converting that position in the map's local coordinate space, meaning that the tap location we're working with is relative to the top-left corner of the map rather than the whole screen or some other coordinate space.

Hacking with Swift is sponsored by Superwall.

SPONSORED Superwall lets you build & test paywalls without shipping updates. Run experiments, offer sales, segment users, update locked features and more at the click of button. Best part? It's FREE for up to 250 conversions / mo and the Superwall team builds out 100% custom paywalls – free of charge.

Learn More

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.6/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.