This project is going to be based around a map view, asking users to add places to the map that they want to visit. To make this work we can’t just embed a simple MKMapView
in SwiftUI and hope for the best: we need to track the center coordinate, whether or not the user is viewing place details, what annotations they have, and more.
So, we’re going to start with a basic MKMapView
wrapper that has a coordinator, then quickly add some extras onto it so that it becomes more useful.
Create a new SwiftUI view called “MapView”, add an import for MapKit, then give it this code:
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView
init(_ parent: MapView) {
self.parent = parent
}
}
}
There’s nothing special there, so let’s change that immediately by making the map keep track of its center coordinate. As we looked at previously this means implementing the mapViewDidChangeVisibleRegion()
method in our coordinator, but this time we’re going to pass that data up to the MapView
struct so we can use @Binding
to store the value somewhere else. So, the coordinator will receive the value from MapKit and pass it up to the MapView
, that MapView
puts the value in an @Binding
property, which means it’s actually being stored somewhere else – we’ve made a little chain that connects MKMapView
to whatever SwiftUI view is embedding the map.
Start by adding this property to MapView
:
@Binding var centerCoordinate: CLLocationCoordinate2D
That will immediately break the MapView_Previews
struct, because it needs to provide a binding. This preview isn’t really useful because MKMapView
doesn’t work outside of the simulator, so I wouldn’t blame you if you just deleted it. However, if you really want to make it work you should add some example data to MKPointAnnotation
so that it’s easy to reference:
extension MKPointAnnotation {
static var example: MKPointAnnotation {
let annotation = MKPointAnnotation()
annotation.title = "London"
annotation.subtitle = "Home to the 2012 Summer Olympics."
annotation.coordinate = CLLocationCoordinate2D(latitude: 51.5, longitude: -0.13)
return annotation
}
}
With that in place it’s easy to fix MapView_Previews
, because we can just use that example annotation:
struct MapView_Previews: PreviewProvider {
static var previews: some View {
MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate))
}
}
We’re going to add more to that in just a moment, but first I want to put it into ContentView
. In this app users are going to be adding places to a map that they want to visit, and we’ll represent that with a full-screen MapView
and a translucent circle on top to represent the center point. Although this view will have a binding to track the center coordinate, we don’t need to use that to place the circle – a simple ZStack
will make sure the circle always stays in the center of the map.
First, add an extra import
line so we get access to MapKit’s data types:
import MapKit
Second, add a property inside ContentView
that will store the current center coordinate of the map. Later on we’re going to use this to add a place mark:
@State private var centerCoordinate = CLLocationCoordinate2D()
And now we can fill in the body
property
ZStack {
MapView(centerCoordinate: $centerCoordinate)
.edgesIgnoringSafeArea(.all)
Circle()
.fill(Color.blue)
.opacity(0.3)
.frame(width: 32, height: 32)
}
If you run the app now you’ll see you can move the map around freely, but there’s always a blue circle showing exactly where the center is.
Although our blue dot will always be fixed at the center of the map, we still want ContentView
to have its centerCoordinate
property updated as the map moves around. We’ve connected it to MapView
, but we still need to implement the mapViewDidChangeVisibleRegion()
method in the map view’s coordinator to kick off that whole chain.
So, add this method to the Coordinator
class of MapView
now:
func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) {
parent.centerCoordinate = mapView.centerCoordinate
}
All this work by itself isn’t terribly interesting, so the next step is to add a button in the bottom-right that lets us add place marks to the map. We’re already inside a ZStack
, so the easiest way to align this button is to place it inside a VStack
and a HStack
with spacers before it each time. Both those spacers end up occupying the full vertical and horizontal space that’s left over, making whatever comes at the end sit comfortably in the bottom-right corner.
We’ll add some functionality for the button soon, but first let’s get it in place and add some basic styling to make it look good.
Please add this VStack
below the Circle
:
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
// create a new location
}) {
Image(systemName: "plus")
}
.padding()
.background(Color.black.opacity(0.75))
.foregroundColor(.white)
.font(.title)
.clipShape(Circle())
.padding(.trailing)
}
}
Notice how I added the padding()
modifier twice there – once is to make sure the button is bigger before we add a background color, and the second time to push it away from the trailing edge.
Where things get interesting is how we place pins on the map. We’ve bound the center coordinate of the map to a property in our map view, but now we need to send data the other way – we need to make an array of locations in ContentView
, and send those to the MKMapView
to be displayed.
Solving this is best done by breaking the problem down into several smaller, simpler parts. The first part is obvious: we need an array of locations in ContentView
, which stores all the places the user wants to visit.
So, start by adding this property to ContentView
:
@State private var locations = [MKPointAnnotation]()
Next, we want to add a location to that whenever the + button is tapped. We aren’t going to add a title and subtitle yet, so for now this is just as simple as creating an MKPointAnnotation
using the current value of centerCoordinate
.
Replace the // create a new location
comment with this:
let newLocation = MKPointAnnotation()
newLocation.coordinate = self.centerCoordinate
self.locations.append(newLocation)
Now for the challenging part: how can we synchronize that with the map view? Remember, we don’t want ContentView
to even know that MapKit is being used – we want to isolate all that functionality inside MapView
, so that we keep our SwiftUI code nice and clean.
This is where updateUIView()
comes in: SwiftUI will automatically call it when any of the values being sent into the UIViewRepresentable
struct have changed. This method is then responsible for synchronizing both the view and its coordinator to the latest configuration from the parent view.
In our case, we’re sending the centerCoordinate
binding into MapView
, which means every time the user moves the map that value changes, which in turn means updateUIView()
is being called all the time. This has been happening quietly all this time because updateUIView()
is empty, but if you add a simple print()
call in there you’ll see it come to life:
func updateUIView(_ view: MKMapView, context: Context) {
print("Updating")
}
Now as you move the map around you’ll see “Updating” printing again and again.
Anyway, all this matters because we can also pass into MapView
the locations
array we just made, and have it use that array to insert annotations for us.
So, start by adding this new property to MapView
to hold all the locations we’ll pass to it:
var annotations: [MKPointAnnotation]
Second, we need to update MapView_Previews
so that it sends in our example annotation, although again I wouldn’t blame you if you had already deleted the preview because it really isn’t useful at this time! Anyway, if you still have it then adjust it to this:
MapView(centerCoordinate: .constant(MKPointAnnotation.example.coordinate), annotations: [MKPointAnnotation.example])
Third, we need to implement updateUIView()
inside MapView
so that it compares the current annotations to the latest annotations, and if they aren’t the same then it replaces them. Now, we could compare each item in the annotations to see whether they are the same, but there isn’t any point – we can’t add and remove items at the same time, so all we need to do is check whether the two arrays contain the same number of items, and if they don’t remove all existing annotations and add them again.
Replace your current updateUIView()
method with this:
func updateUIView(_ view: MKMapView, context: Context) {
if annotations.count != view.annotations.count {
view.removeAnnotations(view.annotations)
view.addAnnotations(annotations)
}
}
Finally, update ContentView
so that it sends in the locations
array to be converted into annotations:
MapView(centerCoordinate: $centerCoordinate, annotations: locations)
That’s enough map work for now, so go ahead and run your app again – you should be able to move around as much as you need, then press the + button to add pins.
One thing you might notice is how iOS automatically coalesces pins when they are placed close together. For example, if you place a few pins in a one kilometer region then zoom out, iOS will hide some of them to avoid making the map hard to read.
SPONSORED From January 26th to 31st you can join a FREE crash course for iOS devs who want to achieve an expert level of technical and practical skills – it’s the fast track to being a senior developer!
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.