12 - Working with Maps
The MapKit framework provides APIs for developers to display maps, navigate through maps, add annotations for specific locations, add overlays on existing maps, etc. With the framework, you can embed a fully functional map interface into your app without any coding.
For SwiftUI, it also comes with a native Map
view for developers to embed a map interface. Optionally, you can display annotations using the built-in annotation views such as MapMarker
.
In this chapter, we will add a map feature to the FoodPin app. The app will display a small map view in the detail screen. When a user taps that map view, the app will bring up a full screen map for users to view the location details. In particular, you will learn a few things about the framework:
- How to embed a map in a view
- How to translate an address into coordinates using Geocoder
- How to add a pin (i.e. annotation) on map
Cool, right? It’s gonna be fun. Let’s get started.
Understanding Map View in SwiftUI
First, let me give you a quick introduction of the Map
view in SwiftUI. By referring the documentation of Map
(https://developer.apple.com/documentation/mapkit/map), you should find the following init
method of the structure:
init(coordinateRegion: Binding<MKCoordinateRegion>, interactionModes: MapInteractionModes = .all, showsUserLocation: Bool = false, userTrackingMode: Binding<MapUserTrackingMode>? = nil) where Content == _DefaultMapContent
To work with Map
, you need to provide a binding of MKCoordinateRegion
that keeps track of the region to display on the map. The MKCoordinateRegion
structure lets you specify a rectangular geographic region centered around a specific latitude and longitude.
Here is an example:
MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 40.75773, longitude: -73.985708), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05))
The coordinates in the above sample is the GPS coordinates of Times Square in New York. The value of span
is used to define your desired zoom level of the map. The smaller the value, the higher is the zoom level.
In general, to embed a map in SwiftUI, you first need to import the MapKit
framework:
import MapKit
And then declare a state variable to keep track of the map region like this:
@State private var region: MKCoordinateRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 40.75773, longitude: -73.985708), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05))
Lastly, you use the Map
structure and pass it with the binding of region
:
var body: some View {
Map(coordinateRegion: $region)
}
Creating Our Own Map View
Now you should have some ideas about the Map
component in SwiftUI. What we're going to do is to add a non-interactive map to the footer of the restaurant detail view. When a user taps the map, the app navigates to a map view controller showing a full-screen map of the restaurant location. Figure 14-1 displays the resulting UI of the app.
Before we can embed a map view in the detail view, let’s begin with our own implementation of MapView
. While we can use the built-in Map
view to display a map, it requires us to provide the exact coordinate in the form of latitude and longtitude. Our own version of MapView
will enhance the built-in version of Map
. The caller can just pass the MapView
a physical address, and the map view will convert the address to a coordinate and display its location on the map.
In the project navigator, right click the View folder and choose New File…. Select the SwiftUI View template and name the file MapView
. Insert the following line of code to import the MapKit framework:
import MapKit
And then replace the MapView
struct like this:
struct MapView: View {
var location: String = ""
@State private var region: MKCoordinateRegion = MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: 51.510357, longitude: -0.116773), span: MKCoordinateSpan(latitudeDelta: 1.0, longitudeDelta: 1.0))
var body: some View {
Map(coordinateRegion: $region)
}
}
The MapView
makes use of the built-in Map
component to display the map interface. But it takes in an additional parameter named location
, which is the address of the location.
By default, the region
state variable is set to the coordinate of London. In Xcode preview, it should show you a map of London.
Converting an Address into Coordinates Using Geocoder
Now that you know how to display a map using latitude and longitude, but how can you pin a location on maps with a physical address? Before we pin the restaurant’s location on the map, let’s understand how to work with a location on maps.
To highlight a location on the map, you cannot just use a physical address. The MapKit framework doesn’t work like that. Instead, the map has to know the geographical coordinates expressed in terms of the latitude and longitude of the corresponding point on the globe.
The framework provides a Geocoder
class for developers to convert a textual address, known as placemark, into global coordinates. This process is usually referred to forward geocoding. Conversely, you can use Geocoder
to convert latitude and longitude values back to a placemark. This process is known as reverse geocoding.
To initiate a forward-geocoding request using the CLGeocoder
class, all you need do is to create an instance of CLGeocoder
, followed by calling the geocodeAddressString
method with the address parameter. Here is an example:
let geoCoder = CLGeocoder()
geoCoder.geocodeAddressString("524 Ct St, Brooklyn, NY 11231", completionHandler: { placemarks, error in
// Process the placemark
})
There is no designated format of an address string. The method submits the specified location data to the geocoding server asynchronously. The server then parses the address and returns you an array of placemark objects. The number of placemark objects returned greatly depends on the address you provide. The more specific the address information you have given, the better the result. If your address is not specific enough, you may end up with multiple placemark objects.
With the placemark
object, which is an instance of CLPlacemark
class, you can easily get the geographical coordinates of the address using the code below:
let coordinate = placemark.location?.coordinate
The completion handler is the code block to be executed after the forward-geocoding request completes. Operations like annotating the placemark will be done in the code block.
Do you still remember what the question mark is for? If you’ve studied chapter 2, I hope you can answer the question. The location
property of the placemark is known as an optional in Swift. Optional is a new type introduced in Swift. It means "there is a value" or "there isn't a value at all." In other words, the location
property may contain a value. To access an optional, you use the question mark. In this case, Swift checks if the location
property has a value or not. If yes, we can further retrieve the coordinate
.
That’s enough for the background information. Let’s continue to write the code for converting a physical address to a coordinate. In the MapView
struct, insert a new method to perform the address conversion like this:
private func convertAddress(location: String) {
// Get location
let geoCoder = CLGeocoder()
geoCoder.geocodeAddressString(location, completionHandler: { placemarks, error in
if let error = error {
print(error.localizedDescription)
return
}
guard let placemarks = placemarks,
let location = placemarks[0].location else {
return
}
self.region = MKCoordinateRegion(center: location.coordinate, span: MKCoordinateSpan(latitudeDelta: 0.0015, longitudeDelta: 0.0015))
})
}
The method takes in an address and tries to find out the location’s longitude and latitude by initiating a forward-geocoding request. If the request is successful, we update the state variable region
to that coordinate.
The guard
statement may be new to you. Earlier, I mentioned we can use the ?
symbol to check if the location
property has a value. In the code above, I showed you another way to perform the checking. Both placemarks
and placemarks[0].location
are option. This guard
statement checks if the placemarks
and placemarks[0].location
contain a value. If the result is positive, the value will save to placemarks
and location
respectively.
Since the location
variable must have a value, we can access the coordinate
value directly.
Now that we created the method for address conversion, when should we call it? This conversion should begin when MapView
is loaded up. Starting from iOS 15, SwiftUI added a modifier called task
for running operations when the view is loaded up.
In the body
variable, attach the task
modifier to the Map
view like this:
Map(coordinateRegion: $region)
.task {
convertAddress(location: location)
}
When the Map
is loaded up, we call the convertAddress
method to convert the address and update the location. To test the changes above, edit the MapView_Previews
struct like below:
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(location: "54 Frith Street London W1D 4SL United Kingdom")
}
}
We initialize the MapView
with a physical address. In the preview pane, the map view should show you the location of that address.
Adding Annotations to the Map
There is a problem with the current map view. It doesn’t point out the exact location of the restaurant on the map. The Map
struct actually provides another init
method which allows developers to pass an array of annotation items:
init<Items, Annotation>(coordinateRegion: Binding<MKCoordinateRegion>, interactionModes: MapInteractionModes = .all, showsUserLocation: Bool = false, userTrackingMode: Binding<MapUserTrackingMode>? = nil, annotationItems: Items, annotationContent: @escaping (Items.Element) -> Annotation) where Content == _DefaultAnnotatedMapContent<Items>, Items : RandomAccessCollection, Annotation : MapAnnotationProtocol, Items.Element : Identifiable
To display the annotations, you are required to provide the annotationContent
describing how the annotation should be presented on the map. For example, you can use MapMarker
to render the annotation like this:
Map(coordinateRegion: $region, annotationItems: [annotatedItem]) { item in
MapMarker(coordinate: item.coordinate, tint: .red)
}
You can create your own type for the annotation item. But there is one requirement you need to fulfill. The type should conform to the Identifiable
protocol. Let's create a new struct named AnnotatedItem
in MapView.swift
:
struct AnnotatedItem: Identifiable {
let id = UUID()
var coordinate: CLLocationCoordinate2D
}
We created a new type named AnnotatedItem
. The type conforms to the Identifiable
protocol and has a property to store the annotation's coordinate. To fulfill the requirement of the Identifiable
protocol, you need to define an id
property which provides an unique identifier. UUID()
automatically generates an universal identifier.
Now let’s use the AnnotatedItem
to prepare the annotation. First, add a new state variable to MapView
for the annotation:
@State private var annotatedItem: AnnotatedItem = AnnotatedItem(coordinate: CLLocationCoordinate2D(latitude: 51.510357, longitude: -0.116773))
This annotatedItem
variable is used to hold the coordinate of the restaurant's location. By default, the coordinate is set to London.
In the convertAddress
method, insert this line of code in the geocodeAddressString
closure:
self.annotatedItem = AnnotatedItem(coordinate: location.coordinate)
After converting the restaurant’s address to a coordinate, we create an instance of AnnotatedItem
with the coordinate. Lastly, update the body
of MapView
to the following code snippet:
Map(coordinateRegion: $region, annotationItems: [annotatedItem]) { item in
MapMarker(coordinate: item.coordinate, tint: .red)
}
.task {
convertAddress(location: location)
}
We use the MapMarker
to render the annotation. Take a look at the preview canvas again. This time, the map view should show a marker to pinpoint the restaurant's location.
Embedding MapView
Now the custom version of MapView
is ready for use. It's time to switch over to RestaurantDetailView.swift
and embed the map view. At the end of VStack
of RestaurantDetailView
, insert the following code to embed the map view:
MapView(location: restaurant.location)
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 200)
.cornerRadius(20)
.padding()
The custom MapView
is just like any other views in SwiftUI, so we can attach the frame
and cornerRadius
modifiers to limit its size and round its corners. To see the embedded map, run the simulator in the preview pane.
Displaying a Full Screen Map
We haven’t completed the UI yet. When a user taps the map in the detail view, it will navigate to another screen which shows a full screen map. To implement the change, all you need to do is to wrap the instantiation of MapView
with NavigationLink
like this:
NavigationLink(
destination:
MapView(location: restaurant.location)
.edgesIgnoringSafeArea(.all)
) {
MapView(location: restaurant.location)
.frame(minWidth: 0, maxWidth: .infinity)
.frame(height: 200)
.cornerRadius(20)
.padding()
}
The destination is set to display a full screen MapView
. Run the app and have a test. If you have made the change correctly, you should achieve the result as shown in figure 14-6.
Disabling User Interaction
By default, the built-in Map allows users to pan and zoom. In some cases, you may want to completely disallow users to interact with the map. You can instantiate the Map
view by specifying the interactionModes
parameter:
Map(coordinateRegion: $region, interactionModes: [])
By setting the Map
view to an empty set, you can disable user interaction. This parameter accepts three other options:
- .all — allows all types of user interaction
- .pan — allows users to pan
- .zoom — allows users to zoom
Exercise
Our custom MapView
does not support interactionModes
. Your task is to modify MapView.swift
to add the feature. The embedded map in the detail view currently allows users to interact with it. Your task is to update the code to disable user interaction.
Summary
In this chapter, I’ve walked you through the basics of the MapKit framework. By now, you should know how to embed a map in your app and add an annotation. But this is just a beginning. There is a lot more you can explore from here. One thing you can do is to further explore MKDirection . It provides you with route-based directions data from Apple servers to get travel-time information or driving or walking directions. You can take the app further by showing directions.
For your reference, you can download the complete Xcode project from