00:06 In this episode, we'll wrap an MKMapView
in a SwiftUI view. In
expanding upon Apple's
tutorial
on the same topic, we will add the ability to provide an array of annotations,
and changes to this array should result in annotations being added and removed
with animations.
Map View with Placemarks
00:43 We start by creating a MapView
that conforms to
UIViewRepresentable
. For this, we have to implement the methods makeUIView
and updateUIView
. In the former, we create the UIView
, and for demo
purposes, we set its visible frame to a predefined region:
struct MapView: UIViewRepresentable {
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
let v = MKMapView()
v.visibleMapRect = .laufpark
return v
}
func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
}
}
struct ContentView: View {
var body: some View {
MapView()
}
}
02:09 That's all we need to do to integrate a map view into SwiftUI. In
order to include some annotations, we add a property that holds an array of
point annotations, which we then pass to the MKMapView
:
struct MapView: UIViewRepresentable {
var placemarks: [MKPointAnnotation]
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
let v = MKMapView()
v.visibleMapRect = .laufpark
v.addAnnotations(placemarks)
return v
}
func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
}
}
struct ContentView: View {
var body: some View {
MapView(placemarks: placemarks)
}
}
02:58 The next step is handling updates to the annotations array.
Stepper
03:12 To quickly play around with updating the map view, we add a
stepper that lets us grow and shrink the slice of the annotations array that's
sent to the map view:
struct ContentView: View {
@State var count = 0
var body: some View {
VStack {
MapView(placemarks: Array(placemarks.prefix(count)))
Stepper("Count \(count)", value: $count)
}
}
}
04:08 When we run the app, the map starts with zero annotations. And the
map view doesn't yet update when we increase the count by using the stepper.
That's because we never update the annotations of the MKMapView
in the
updateUIView
method:
struct MapView: UIViewRepresentable {
var placemarks: [MKPointAnnotation]
func makeUIView(context: UIViewRepresentableContext<MapView>) -> MKMapView {
let v = MKMapView()
v.visibleMapRect = .laufpark
return v
}
func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
mapView.removeAnnotations(mapView.annotations)
mapView.addAnnotations(placemarks)
}
}
04:58 When we increment and decrement the number of annotations with the
stepper, we can see that all visible annotations flash because they all get
removed and then added in every update.
Diffing
05:23 In order to make our updates smarter, we have to look at the
current annotations in the MKMapView
and compare them with the annotations in
the placemarks
array. By using the difference
API on Array
, we can find
out which annotations have been added and removed.
06:00 The map view may have different types of annotations, so we have
to first filter out any annotation that isn't an MKPointAnnotation
:
struct MapView: UIViewRepresentable {
func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }
}
}
06:48 Now we can find the differences between the new placemarks
annotations and the old annotations from the map view. For this, we need to tell
the difference
API whether or not two given annotations are equal, and since
annotations are objects, we can use pointer equality to do so:
struct MapView: UIViewRepresentable {
func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }
let diff = placemarks.difference(from: existing) { $0 === $1 }
}
}
07:50 Then we process the differences by adding and removing annotations
as needed:
struct MapView: UIViewRepresentable {
func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }
let diff = placemarks.difference(from: existing) { $0 === $1 }
for change in diff {
switch change {
case .insert(_, let element, _): mapView.addAnnotation(element)
case .remove(_, let element, _): mapView.removeAnnotation(element)
}
}
}
}
09:01 We run the app and we tap the stepper a few times to add and
remove some annotations. We see that most of the annotations — and not only the
one that is being added or removed — are still being animated. This is caused by
differences in how each of the two annotation arrays is sorted. When we get the
annotations back from the map view, they can be in a different order than they
were when we first passed them in.
10:02 We should sort the annotations before calculating the differences
between the two arrays. And it doesn't really matter how we sort them, as long
as the old and new array are both sorted in the same, stable way. The easiest
way to achieve this is by sorting the annotations by latitude:
struct MapView: UIViewRepresentable {
func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }.sorted { (lhs, rhs) -> Bool in
lhs.coordinate.latitude < rhs.coordinate.latitude
}
}
}
11:05 In order to reuse the same sort function for the placemarks
array, we pull the function:
extension MKPointAnnotation {
static func <(lhs: MKPointAnnotation, rhs: MKPointAnnotation) -> Bool {
lhs.coordinate.latitude < rhs.coordinate.latitude
}
}
12:04 It's a bad idea to conform MKPointAnnotation
to Comparable
because we don't own the type, so instead we explicitly use the <
function to
sort both arrays:
struct MapView: UIViewRepresentable {
func updateUIView(_ mapView: MKMapView, context: UIViewRepresentableContext<MapView>) {
let existing = mapView.annotations.compactMap { $0 as? MKPointAnnotation }.sorted(by: <)
let diff = placemarks.sorted(by: <).difference(from: existing) { $0 === $1 }
for change in diff {
switch change {
case .insert(_, let element, _): mapView.addAnnotation(element)
case .remove(_, let element, _): mapView.removeAnnotation(element)
}
}
}
}
12:16 Now we have a stable order, and the result from difference
actually makes sense. This is proven by the fact that the annotation that is
being added or removed is the only one that gets animated.
Set Difference
12:40 In this case, we could've used a Set
to store the placemarks
because their presentation doesn't rely on the order in which they're stored. In
other cases where order does matter, such as in a list of table cells, the
difference
API is powerful because it allows us to animate changes in order.
13:28 There are lots of things we could do to further improve the
integration between UIKit and SwiftUI. Instead of the hardcoded map region, we
could use a binding for the map's visible rectangle and respond to the user
panning the map. This would require us to set up a map view delegate via a
coordinator in the UIViewRepresentable
's context.
14:03 Another cool thing, which wouldn't be very difficult to do, is to
use the UIViewRepresentable
coordinator for drawing custom SwiftUI views that
replace the built-in point annotations. We will return to this in a future
episode.