Summarising "Meet MapKit for SwiftUI" from WWDC23 šŸ˜ƒ

Summarising "Meet MapKit for SwiftUI" from WWDC23 šŸ˜ƒ

A recap of the WWDC23 session by Jeff Meininger of the Apple MapKit team

Ā·

18 min read

All content is Apple copyrighted and is reproduced here for personal learning only! It has been a fantastic session.

It will be also included in the open-source project: WWDC Notes

Watch it here:

And follow the article for a recap with code and screenshots.

MapKit for SwiftUI has gotten some fantastic updates recently. Let's build a fully functional trip planner from scratch!

  • We will use annotations to mark places on the map.

  • And enable selection so that I can tap on each marker to learn more about that place.

  • Integrate Look Around to explore some places we might want to visit.

  • Add an overlay that shows a driving route to the beach.

  • We use the map to display different locations and regions.

  • Add another dimension to the map by enabling realistic elevation.

  • Show how to display satellite and flyover imagery as well.

  • Add some controls to the map, including a user location button, so that I can figure out where I am.

Let's start with a brand-new SwiftUI project

I remove the boilerplate code, add import MapKit and Map() to the ContentView() and this alone is already enough to show the map in the preview.

import SwiftUI
import MapKit

struct ContentView: View {
    var body: some View {
        Map ()
    }
}

Nice! I have an interactive map with just one line of code!
I like the parking garage right underneath the Common. The first thing I'll do is add some content to the map to mark the parking garage.

Show content on the map

I'll use a MapContentBuilder closure to add a marker to the map.

extension CLLocationCoordinate2D {
    static let parking = CLLocationCoordinate2D(
        latitude: 42.354528, longitude: -71.068369
    )
}

and

import SwiftUI
import MapKit

struct ContentView: View {
    var body: some View {
        Map () {
            Marker("Parking", coordinate: .parking)
        }
    }
}

Adding a Marker to the map is a lot like adding a View to a List.
The map has automatically framed our content by zooming in to show the Marker.
Markers are used to display content at a specific coordinate on the map. The balloon shape might look familiar to you. Youā€™ll find Markers used in the Maps app and across the platform, including in a wide variety of apps you can find on the App Store.

Map {
    Marker ("Sign-in", systemImage: "figure.wave", coordinate: signIn)
}

Like Marker, Annotation is used to display content at a specific coordinate.

    Annotation(
        "Sign-in", 
        coordinate: signIn, 
        anchor: .bottom
    ) {
        Image(systemName: "figure.wave")
            .padding (4)
            .foregroundStyle(.white)
            .background (Color.indigo)
            .cornerRadius (4)
    }

Instead of Markerā€™s balloon, Annotation displays a SwiftUI View.
The content builder can be used to present overlay content as well. You can use the content builder closure to add all kinds of content to the map.

        MapCircle(center: islandCenter, radius: islandRadius)
            .foregroundStyle(.orange.opacity(0.75))

        MapPolyline(coordinates: sidewalk)
            .stroke(.blue, linewidth: 13)

        MapPolygon(coordinates: dock)
            .foregroundStyle(.purple)

I want to display a custom SwiftUI view for the parking spot, so Iā€™ll use an Annotation to mark it. Here, Iā€™m using ZStack to compose some shapes and an image. This SwiftUI view will be displayed on the map centered right on the parking coordinate. If youā€™d like your view to be positioned above the coordinate instead, you can use Annotationā€™s anchor parameter. Specifying an anchor value of ā€œbottomā€ will position the bottom of your view right on the annotationā€™s coordinate. Iā€™ve used a MapContentBuilder to display annotation content on the map.

struct ContentView: View {
    var body: some View {
        Map() {
            Annotation("Parking", coordinate: .parking) {
                ZStack {
                    RoundedRectangle(cornerRadius: 5)
                        .fill(.background)
                    RoundedRectangle(cornerRadius: 5)
                        .stroke(.secondary, lineWidth: 5)
                    Image(systemName: "car")
                        .padding(5)
                }
            }
            .annotationTitles(.hidden) // hide the title, icon only
        }
    }
}

Give the user a sense of place

Iā€™ll use mapStyle to achieve that by enabling realistic terrain elevation. You can set a style using the mapStyle modifier. I add this after the closing curly bracket of Map:

.mapStyle(.standard)

This is the standard map style. By default, it offers a flat presentation much like a physical paper map.
It looks like thereā€™s a bridge across the lagoon, so you can walk from one side to the other. This flat map really leaves something to the imagination, though. Iā€™ll enable realistic elevated terrain to give the map another dimension to work with.

.mapStyle(.standard(elevation: .realistic))

Using the imagery map style is another great way to offer your users a sense of place.
The imagery map style displays a map rendered using satellite or flyover imagery.

.mapStyle(.imagery(elevation: .realistic))

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

The Hybrid map style combines imagery with roads and labels.

Add a Search to the App

Next, Iā€™d like the app to help us search for the places we want to visit.

We add a SwiftUI file, BeantownButtons. Iā€™ll add a button to search for playgrounds, and a button to search for beaches. The app will add a Marker for each search result. Tapping a button calls a search function with a simple query, either playground or beach.

HStack {
    Button {
        search(for: "playground")
    } label: {
        Label("Playgrounds", systemImage: "figure.and.child.holdinghands")
    } 
    .buttonStyle(.borderedProminent)

    Button {
        search(for: "beach")
    } label: {
        Label("Beaches", systemImage: "beach.umbrella")
    } 
    .buttonStyle(.borderedProminent)
}
.labelStyle(.iconOnly)

The search function uses MKLocalSearch to find places near the Boston Common parking garage, and writes the results using a binding. We add this to the new BeantownButtons view.

@Binding var searchResults: [MKMapItem]

func search(for query: String) {
    let request = MKLocalSearch.Request()
    request.naturalLanguageQuery = query
    request.resultTypes = .pointOfInterest
    request.region = MKCoordinateRegion(
        center: .parking,
        span: MKCoordinateSpan (latitudeDelta: 0.0125, longitudeDelta: 0.0125)
        )

    Task {
        let search = MKLocalSearch(request: request)
        let response = try? await search.start()
        searchResults = response?.mapItems ?? []
    }
}

Back in the appā€™s main ContentView, Iā€™ll add State to keep track of the search results.

@State private var searchResults: [MKMapItem] = []

When the BeantownButtons UI performs a search, it will write the results back to this state using a binding.

Iā€™ll add the buttons above the map at the bottom of the screen. Using safeAreaInset will make sure the appā€™s UI doesnā€™t obscure any of the content Iā€™m adding or any system-provided controls that can appear on the map, such as the Apple Maps logo and Legal link. I put this code below the mapStyle modifier.

    .safeAreaInset(edge: .bottom) {
        HStack {
            Spacer()
            BeanTownButtons(searchResults: $searchResults)
                .padding(.top)
            Spacer()
        }
        .background(.ultraThinMaterial)
    }

Next, Iā€™ll use the content builder to add search result Markers. I'm using ForEach to add a marker for each search result. I add this below the annotationTitles modifier.

    ForEach(searchResults, id: \.self) { result in
        Marker(item: result)
    }

The search results are MKMapItems, which is the type MapKit APIs like MKLocalSearch use to represent places. Here, Iā€™m using Markerā€™s map item initializer. Markers created this way use the map itemā€™s name for their title and use information from the map item to show an icon and tint color that represent the place. Most of these search results show as light blue beach umbrella markers. When youā€™re working with map items, Markerā€™s automatic content and style support is very convenient.

Even if you arenā€™t using map items, though, you still have control over the Markerā€™s presentation. By default, Marker shows a map pin icon in its balloon. You can provide your own icon using an Image asset or a system image. You can also show up to three letters of text using monogram. You can change the Markerā€™s color using the tint modifier.

// Markers with custom content and tint
Map {
    Marker("Parking", systemImage: "car.fill",
        coordinate: parking
    )
    .tint(.mint)

    Marker("Foot Bridge", monogram: "FB", 
        coordinate: bridge
    )
    .tint(.blue)

    Marker ("Ducklings", image: "DucklingAsset",
        coordinate: ducklings
    )
    .tint(.orange)
}

Next, Iā€™m going to put the app in control of whatā€™s displayed by the map.

Display a Place or a Region

Iā€™ve been adding content to the map. Each time I have, the map has automatically framed my content for me. Iā€™ll show you how to enable this convenient behavior when you need it.

If I pan away and search for playgrounds... the map no longer automatically displays the results near our Boston Common parking spot. To display the search results after the user has interacted with the map, Iā€™ll need to re-set the Mapā€™s camera position state so that the map will frame the markers...

Iā€™ll add state to track the position. Iā€™ll use the default automatic position that frames the content weā€™ve added to the map.

@State private var position: MapCameraPosition = .automatic

And Iā€™ll pass the binding to Mapā€™s initializer.

Map(position: $position) { ... }

Iā€™ll use an onChange modifier to find out when the search results are updated. When they are, Iā€™ll simply set the camera position back to automatic to make sure theyā€™re visible.

        .onChange(of: searchResults) {
            position = .automatic
        }

Letā€™s give it a try. Iā€™ll search for beaches, see the results... and then pan away before searching for playgrounds.
Cool! Now when I perform a search, the results are all displayed even if I had panned all the way to Rhode Island.
Iā€™ll add coordinate regions for the city and for the North Shore.

extension MKCoordinateRegion {
    static let boston = MKCoordinateRegion(
        center: CLLocationCoordinate2D(
            latitude: 42.360256, longitude: -71.057279),
            span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)
    )

    static let northShore = MKCoordinateRegion(
        center: CLLocationCoordinate2D(
            latitude: 42.547408, longitude: -70.870085),
            span: MKCoordinateSpan(latitudeDelta: 0.5, longitudeDelta: 0.5)
    )
}

I'll switch over to the BeantownButtons UI and I'll add a binding for our position state.

@Binding var position: MapCameraPosition

Iā€™ll add a couple of buttons, each setting the camera position to a region.
When I press the City button, the Map will show Boston. When I press the Waves button, the map will show the north shore coastline.
In BeantownButtons I add buttons in the bottom view together with a binding to position:

    Button {
        position = .region(.boston)
    } label: {
        Label("Boston", systemImage: "building.2")
    }
    .buttonStyle(.bordered)

    Button {
        position = .region(.northShore)
    } label: {
        Label("North Shore", systemImage: "water.waves")
    }
    .buttonStyle(.bordered)

Iā€™ll switch back to ContentView and pass a position binding to the buttons UI.

BeanTownButtons(position: $position, searchResults: $searchResults)

Control What the Map Displays

  • Explicit: MapCamera

  • Semantic Positioning: MapCameraPosition

When I tapped the ā€œwavesā€ button, the mapā€™s position was updated to show the north shore coastline region. When I tap the ā€œcityā€ button, itā€™s updated to show Boston. Behind the scenes, what the Map shows is ultimately controlled by a MapCamera. The camera looks at a coordinate on the ground from a certain distance and the orientation of the camera determines what is visible in the map. The app Iā€™m building has not had to create or configure the camera itself. Instead, it simply specifies what should be in view using MapCameraPosition. MapKit takes care of the camera for me.

Position the camera: MapCameraPosition

Control what place or region is displayed:

  • automatic
    The app uses an automatic camera position to frame content, such as search results.

  • region (MKCoordinateRegion)
    It uses a region position to show Boston and the North Shore.

  • rect (MKMapRect)
    You can specify a camera position to frame other things, as well. Rect position is used to show an area, just like how weā€™ve used region. It simply uses a map rect to represent that area, instead of a coordinate region.

  • item (MKMapItem)

  • camera (MapCamera)

  • userLocation()

Using MKMapItem, you can show a particular place. This works for all kinds of map items. If your map item represents Cape Cod Bay, MapKit will automatically zoom out so that it fits. If youā€™re trying to show a certain park in the North End, the camera will zoom in to show the surroundings and deliver a sense of place.

position = .item(.capeCodBay)

You can also simply supply a MapCamera, configured exactly the way you want it. Using a MapCamera with a pitch angle is a great way to deliver a 3D perspective.

// Camera position with an exact MapCamera configuration
position = .camera (
    MapCamera (
        centerCoordinate: CLLocationCoordinate2D(
            latitude: 42.360431, 
            longitude: -71.055930
    ), 
    distance: 980, 
    heading: 242, 
    pitch: 60
    )
)

Or, perhaps youā€™d like the camera to follow the userā€™s location as theyā€™re walking along the Charles River. You can supply a fallback position that will be used when the userā€™s location is not known, such as when location authorization has not been granted or while the device is trying to get a location fix. If you provide a binding to your camera position state, MapKit will update it when the camera position changes.

// Camera position that follows the user's location
position = .userLocation(fallback: .automatic)

Here is a user location camera position. The followsUserLocation property is true.

position.followsUserLocation == true

If the user pans away, the camera is no longer following the userā€™s location.
When the user interacts with the map, the camera position state is positionedByUser.

position.followsUserLocation == false
position.positionedByUser == true

When your app sets the camera position state, it is not positionedByUser.

position.followsUserLocation == true
position.positionedByUser == false

Search in the visible region

The app is now in control of whatā€™s in view on the map. Iā€™ve used the automatic camera position to ensure the search results are visible even after the user has interacted with the map. Iā€™ve used the region camera position to display Boston and the North Shore.
Next up, instead of only searching near Boston Common, Iā€™d like to pan the map to an area Iā€™m interested in visiting and search there instead. Iā€™ll show you how to get the visible region when the camera changes.

Iā€™ll add state to track the region thatā€™s visible in the map. Iā€™ll add an onMapCameraChange modifier, where Iā€™ll grab the visible region from the update context and stash it in my own state.

@State private var visibleRegion: MKCoordinateRegion?

and

        .onMapCameraChange { context in
           visibleRegion = context.region
       }

By default, the closure supplied to onMapCameraChange will be called when the user has finished interacting with the map. To have the closure called while the user is interacting with the map, you can request continuous updates by passing a frequency parameter. In addition to the region property that Iā€™m using here, the context also has a property for the visible map rect and one for the map camera itself. Depending on my needs, I could use those as well.

Iā€™ll update BeantownButtons so that it will search within the region thatā€™s visible to the user. Iā€™ll add the visibleRegion to the buttons.

    var visibleRegion: MKCoordinateRegion?

And Iā€™ll use it in the search request.

            [...]
        request.region = visibleRegion ?? MKCoordinateRegion(
            center: .parking,
            span: MKCoordinateSpan(
                latitudeDelta: 0.0125, 
                longitudeDelta: 0.0125))
            [...]

In ContentView, Iā€™ll pass the visibleRegion to the buttons UI.

    BeanTownButtons(
        position: $position, 
        searchResults: $searchResults, 
        visibleRegion: visibleRegion
        )

Iā€™ve enabled this using onMapCameraChange, which informs us when thereā€™s been a change in whatā€™s visible.

Interact with search results

Enable selection for Map Content

Next, Iā€™d like the app to make it a little easier to pick which beach weā€™ll be going to. To get started, Iā€™ll add support for selecting a search result. Right now, if I tap on a search result marker, nothing will happen. There is no selection state, so the markers are not selectable. To enable selection, Iā€™ll just add a selection binding to our Map.

@State private var selectedResult: MKMapItem?

The balloon animates to show that itā€™s selected. Iā€™m using MKMapItem as the selection type, so each marker that represents a map item is now selectable. The Parking Spot annotation doesnā€™t represent a map item, so it is not selectable.
If you want to support selection for Markers and Annotations that donā€™t necessarily have the same type of identity, you can simply tag them. This works the same way it does when managing selection with Picker and List. Here, the selectedTag state is an Int. Each marker is tagged with an Int, so the binding enables selection for both of them. When using tag to enable selection, you can use any type conforming to hashable for your selection state.

Display useful search-result information

  • Look Around

  • Place name

  • Travel time

Next, the app should display some additional information about the selected search result. Iā€™ll add a look around preview to offer a sneak peek at the beach, and Iā€™ll add the name of the beach and the drive time as well.

Let's make a new SwiftUI file called ItemInfoView that shows... a title, the estimated travel time, and a Look Around Preview.

    var body: some View {
        LookAroundPreview(initialScene: lookAroundScene)
            .overlay(alignment: .bottomTrailing) {
                HStack {
                    Text ("\(selectedResult.name ?? "")")
                    if let travelTime {
                        Text(travelTime)
                    }
                }
                .font(.caption)
                .foregroundStyle(.white)
                .padding (10)
            }
            .onAppear {
                getLookAroundScene()
            }
            .onChange(of: selectedResult) {
                getLookAroundScene()
            }
    }

The Look Around Preview will show me what the selected beach looks like. The Preview displays a Look Around Scene. You can get the scene for a given map item using MKLookAroundSceneRequest. The scene will be fetched when the view is displayed, and again any time the selected search result changes.

    // ItemInfoView.swift - Fetch a Look Around scene
    @State private var lookAroundScene: MKLookAroundScene?

    func getLookAroundScene () {
        lookAroundScene = nil
        Task {
            let request = MKLookAroundSceneRequest(mapItem: selectedResult)
            lookAroundScene = try? await request.scene
        }
    }

Finally, thereā€™s a property that formats an MKRouteā€™s expected travel time for display, using DateComponentsFormatter.

// ItemInfoView.swift - Format travel time for display
    private var travelTime: String? {
        guard let route else { return nil }
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .abbreviated
        formatter.allowedUnits = [.hour, .minute]
        return formatter.string(from: route.expectedTravelTime)
    }

Iā€™ll switch back to ContentView and add this ItemInfoView. Iā€™ll add state to keep track of a route...

@State private var route: MKRoute?

and Iā€™ll add a function that uses MKDirections to get one... and set the state.

func getDirections() {
    route = nil
    guard let selectedResult else { return }
    let request = MKDirections.Request()
    request.source = MKMapItem(placemark: MKPlacemark(coordinate: .parking))
    request.destination = selectedResult

    Task {
        let directions = MKDirections(request: request)
        let response = try? await directions.calculate()
        route = response?.routes.first
    }
}

Iā€™ll add another onChange modifier to call the function when the selection changes. The app will show our item info view when it has a selected search result.

        .onChange(of: selectedResult) {
            getDirections()
        }

and

    VStack(spacing:0) {
        if let selectedResult {
            ItemInfoView(selectedResult: selectedResult, route: route)
                .frame(height: 128)
                .clipShape(RoundedRectangle (cornerRadius: 10))
                .padding([.top, .horizontal])
        }
        BeantownButtons( //...
    }

While Iā€™m at it, Iā€™ll hide the Marker titles for the search results to clean up the appearance of the map just a bit. The ItemInfoView will display the name of the selected place instead.

    ForEach (searchResults, id: \.self) { result in
        //...
    } 
    .annotationTitles(.hidden) // hide the Marker titles

To recap, Iā€™ve added a look around preview that will be displayed when a marker is selected. Along with the estimated travel time from MKRoute.

lookaround1

Show Overlay Content

Next, since we already have a route to show the travel time, we should totally use it to display the driving route from Boston Common to the selected search result. Iā€™ll add a MapPolyline overlay to show the route, and Iā€™ll show you some other types of overlay content you can add as well. When a route is available, Iā€™ll add a MapPolyline, and stroke it with blue.

    if let route {
        MapPolyline(route)
            .stroke(.blue, lineWidth: 5)
    }

You can also use MapPolyline to show your own location data. You can use StrokeStyle to deliver some pretty fancy stuff, such as dashes and a gradient.

If youā€™re looking to highlight an area, youā€™ll want to use MapPolygon or MapCircle. Here are two polygons that mark a couple of parks.

Here are two circles marking the same parks. Youā€™ll notice that an overlay level is specified for each circle. The pink circle is using the default overlay level of above roads, which puts the mapā€™s labels above the circle. The cyan circle is using above labels.

User Location and Map Controls

  • Show the user's location

  • Learn about Map Control views

I want the app to make it really easy to figure out where I am. Iā€™ll add UserAnnotation to the map content to show where I am:

UserAnnotation()

and Iā€™ll add a MapUserLocationButton to find myself.

    .mapControls {
        MapUserLocationButton()
        MapCompass()
        MapScaleView()
    }

Now, I can tap the button to display my location. The map camera will follow me as I move around. Iā€™ve also added a MapCompass and a MapScaleView.

The default mapControls configuration shows a compass when the map is rotated, and a scale indicator while the user is zooming in or out. I want these default controls in this app too, so Iā€™ve specified them in addition to the user location button.

Iā€™ve added all of these using the mapControls modifier, so the map will automatically display them in their default locations. This includes map controls on all platforms, including the MapZoomStepper and MapPitchSlider that youā€™ll find on macOS.

If youā€™d prefer to position these controls yourself, you can present them in your own UI. The Map controls are simply views, so instead of using the mapControls modifier, you can just add them as you would any other view. When you do this, you will need to use the mapScope modifier to associate your controls with a particular Map scope.

// Custom positioning of map controls

@Namespace var mapScope

var body: some View {
    Map(scope: mapScope)
        .overlay(alignment: .bottomTrailing) {
            VStack {
                MapUserLocationButton(scope: mapScope)
                MapPitchButton(scope: mapScope)
                MapCompass(scope: mapScope)
                    .mapControlVisibility(.visible)
            }
            .padding(.trailing, 10)
            .buttonBorderShape(.circle)
        }
        .mapScope(mapScope)
}

Letā€™s summarize what we've learned today.

MapKit for SwiftUI allows you to use Markers, Annotations, and Overlays to show your content on a map. Map Camera and Map Controls allow you to tailor the map to your needs. Finally, MapStyle and Look Around give your users a real sense of place. These are just some of the features of MapKit for SwiftUI, so make sure to check out the Developer Documentation to learn more. And of course, because this is SwiftUI, your map will look great on all platforms!

A few final thoughts

Apple has extended the Apple Maps Server APIs to support Autocomplete and Directions. To learn more about how to use the Server APIs, check out last yearā€™s dub-dub session ā€œMeet Apple Maps Server APIs.ā€
Last, but not least, check out the new features in SwiftUI this year. Animation plans are a great way to add animations to your map! Check them out in the session below.

Check out also

Wind your way through advanced animations in SwiftUI - WWDC23
Meet Apple Maps Server APIs - WWDC22