Code-Along Project With MapKit for SwiftUI - Part 2
MapKit for SwiftUI got some major additions for iOS17, let's see them together!
(For iOS17 and Xcode 15 beta)
In iOS 17 and macOS 14, MapKit has reached a new level with a few new additions for SwiftUI. Keep in mind that these views and modifiers are still in beta and are subject to change without notice.
This brief series of posts draws inspiration from the WWDC23 talk: WWDC23 - Meet MapKit for SwiftUI
This is a code-along project, the main branch On GitHub has the starter project in Xcode. See below for the links or start with part one!
The next episodes in this series will show how to display a route and how to integrate LookAround into your project.
Please see the first part of the series here:
Part 2 - Controlling the region being displayed
In part 1 we saw how to add markers on the map with a simple natural language search. The link is above.
In this post, we will talk about the map camera and how to have control over the region being displayed.
Right now our map performs a search based on our location on the map. Also when the map gets displayed we make use of the defaults hidden in the map initializer. By default, since we do not pass anything in the initialiser, the map will display a region big enough to show the elements in its closure, the map content builder.
We could call this behaviour automatic.
But after the user interacts with the map, for instance, with a swipe gesture, and then pressing the button again, the map will move. It will not show the results anymore because I moved its focus. What I mean by this is that the results are still being displayed virtually, just not in the region I am looking at and the map is not moving back to the results.
Let's see how I can fix that.
The Map initializer
We take a step back and we look at the Map initializer. It has a few options.
When we do right-click on the Map keyword we have the option to see the documentation. It is always recommended to have an interesting to look at the documentation provided by Apple, especially since it gets better and better all the time!
In SwiftUI Map
is defined as a view that displays an embedded map interface and is available when SwiftUI is imported with MapKit, and this has already been since iOS14!
Behind the scenes, there is a MapContentBuilder
which adds content to your map including, markers, annotations, and overlays which we already know from the UIKit version of Map.
Some of the older initialisers are now deprecated in iOS17. New are a family of initialiser taking a mix of:
position
bounds
: The bounds to restrict the map's camera movement to.interactionModes
: The ways users are allowed to interact with the map.scope
: The scope associated with the map.
An example of a Map initializer:
init<SelectedValue, C>(
position: Binding<MapCameraPosition>,
bounds: MapCameraBounds? = nil,
interactionModes: MapInteractionModes = .all,
selection: Binding<SelectedValue?>,
scope: Namespace.ID? = nil,
@MapContentBuilder content: () -> C
) where Content == MapContentView<SelectedValue, C>, SelectedValue : Hashable, C : MapContent
And when I type 'Map(' this translates in Xcode to:
I can pass a camera position and a binding to a selection on the map. This is something we will do later.
@State private var position: MapCameraPosition = .automatic
@State private var selectedResult: MKMapItem?
var body: some View {
Map(position: $position, selection: $selectedResult) {
// ...
}
}
MapCameraPosition
Now, since I do not pass anything I will have the map automatically displaying my content.
So let's talk about MapCameraPosition again.
The Apple define it: "A structure that describes how to position the map’s camera within the map."
As in the developer docs:
When you pass
MapCameraPosition
as a binding to a map, the map adjusts its camera to frame the requested content, or to exactly match the cameraMapCameraPosition
specifies. If a person interacts with theMap
in a way that moves the map, the map resets the position to a value that specifiespositionedByUser
.
This becomes useful if I pan out of the map, and want to go back to my results when I press the search button again.
I added a new @State property to my view. The type is MapCameraPosition
. The default is automatic which will show all of my results when first launched.
@State private var position: MapCameraPosition = .automatic
and I will need to pass the binding to the map initialiser too:
Map(position: $position){
...
But then, imagine I panned till Paris, I am outside of the automatic mode and need to come back to it when I search again for my results near Rouen! I added this to the map below the safeAreaInset
block:
.onChange(of: searchResults) {
position = .automatic
}
The value I am using here: .automatic
will automatically adapt the map region to frame my contents. When I pan away this property changes to positionedByUser
and the map will not necessarily display my content again. Therefore I need to reset it to .automatic
every time I ask for new search results... The onChange
modifier is the perfect place to reset the position to automatic or to whatever we prefer.
Displaying a MKMapRegion
But apart from displaying my results automatically, how else can I control what the map displays? I can use my position
state to control what the map displays. With MKMapRegion I can explicitly define an area to be displayed by the camera.
To demonstrate this, I will add a new extension to MKMapRegion to add two static variables. This is just a convenience. I create two new regions which take a CLCoordinate2D and a span... The span will define the amount of region I want to see around the coordinate. A bit like zooming in.
extension MKCoordinateRegion {
static let étretat = MKCoordinateRegion (
center: CLLocationCoordinate2D( latitude: 49.7071, longitude: 0.2064),
span: MKCoordinateSpan ( latitudeDelta: 0.1, longitudeDelta: 0.1)
)
static let honfleur = MKCoordinateRegion (
center: CLLocationCoordinate2D( latitude: 49.4194, longitude: 0.2333),
span: MKCoordinateSpan( latitudeDelta: 0.1, longitudeDelta: 0.1)
)
}
I will add two coastal regions on the coast in Normandy
Étretat is a picturesque coastal town known for its stunning cliffs and natural arches. The area's unique geological formations attract visitors from around the world. The coordinates provided are for the general area of Étretat.
Span: Honfleur is a charming port town with a historic harbour, colourful buildings, and cobbled streets. The town's maritime heritage and artistic influence make it a popular destination. The coordinates provided are for the central area of Honfleur.
I will now add a couple of buttons, each setting the camera position to a region. I will first add the position as a Binding variable to my MapButtonView.swift file. It is a Binding because it is a state not owned by my button view and also it is a two-way data flow, from the main view to the button and vice versa. Mostly this is always the case with the buttons, I use them in a subview to change the state in my app.
// in MapButtonView.swift
@Binding var position: MapCameraPosition
// add this to my Hstack
Button {
position = .region(.honfleur)
} label: {
Label("Honfleur", systemImage: "water.waves")
}
.buttonStyle(.bordered)
and the compiler will still not be happy yet, we need to pass the position binding as an argument of our MapButtonView in our main ContentView file as we did for our searchResults.
MapButtonView(
searchResults: $searchResults,
position: $position)
Some refinements
Up until now, our map has been capable of visualizing cafes and beaches in two specific locations. We have two buttons for two locations, a button for looking for beaches and one for cafes. But the user pans away, simply tapping the search buttons again will automatically reset the map to display the region where we originally searched for results. This is not what we want.
If you followed until here, you might ask why searching for cafes brings us back to the original region. The answer lies within our code, specifically in how we request locations in our MapButtonView. In the search function, we explicitly instruct Maps to look for results within the MKCoordinate regions centred on a hardcoded location. Remember the search(for:) function in our ButtonView? This is what we'll be changing. Start by adding the following property to the mapButtonView:
var visibleRegion: MKCoordinateRegion?
Replace this line:
request.region = MKCoordinateRegion (
center: .start,
span: MKCoordinateSpan (latitudeDelta: 0.0125, longitudeDelta: 0.0125))
with this where we check if we have a new visible region to search into and if not we will use the default:
request.region = visibleRegion ?? MKCoordinateRegion (
center: .start,
span: MKCoordinateSpan (latitudeDelta: 0.0125, longitudeDelta: 0.0125))
In ContentView, we pass the `visibleRegion' property to the buttons too:
MapButtonView(
searchResults: $searchResults,
position: $position,
visibleRegion: visibleRegion
)
No need for a binding here since we're not altering the visible region in the button view. Do you notice the subtle distinction? In the revised code block, we instruct Maps to initially search within the currently displayed app region. If none is found, it will default to the hardcoded one.
Now, when we pan away and tap the coffee cup button to search for cafes, the app will search in the new region without abruptly reverting to the hardcoded location. This is a better user experience.
MKMapRect
There is a third way to display an area on the map. With MKMapRect. Let's see it in the documentation first:
If you project the curved surface of the globe onto a flat surface, what you get is a two-dimensional version of a map where longitude lines appear to be parallel. Such maps are often used to show the entire surface of the globe all at once. An
MKMapRect
data structure represents a rectangular area as seen on this two-dimensional map.
This is quite interesting. Again, demonstrate it I will use it to display a region. This time we will do something a bit more exciting. Let's display the whole world!
We could start to add an extension of MKMapRect and again for convenience initialize a rectangle eith one of the two MKMapRect initializers:
And we would pass this in our button like
position = .rect(.myRect)
this would display our region. But I want to show you a cool another option!
We will be displaying the whole world, or what is possible on our iPhone display. BTW, this apparently would work in the xrOS beta as well! I wonder what it will look like with the Vision Pro...
In this case, we do not need to add the extension because the MKMapRect has already a static .world property. We just added another button:
Button {
position = .rect(.world)
} label: {
Label("world", systemImage: "globe")
.frame(width: 44, height: 44)
}
.buttonStyle(.bordered)
and pressing it will show our global flattened map:
I think it looks quite nice!
See you next week for more! 🌍
Wrapping up
This week we saw how to control what is displayed on the map:
with
.automatic
using
region(MKCoordinateRegion)
with
rect(MKMapRect)
Next time we will look at how to display animations, routes and more!
Relevant Links
The GitHub repo with the project
The GitHub repo with the completed part 2 project