12 - Working with Maps

NPSoftware
10 min readFeb 28, 2023

--

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.

Figure 14–1. Adding a map view to the detail view

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.

Figure 14–2. Displaying a map

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 locationproperty 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 guardstatement checks if the placemarks and placemarks[0].locationcontain 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.

Figure 14–3. The map view can now convert an address and display its location

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.

Figure 14–4. The map view shows a marker for the annotation

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.

Figure 14–5. Embedding the map in the detail view

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.

Figure 14–6. Displaying a full screen map

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

--

--

NPSoftware

I'm self-studying iOS development to become a skilled app developer. Please share any helpful insights or advice, and feel free to correct any mistakes🥰.