Creating an Image from an MKMapView
Apple's MapKit provides some great tools to add maps to your Mac or iOS apps. Unfortunately, it may not provide all the features you need. SwiftUI has its own implementation of MapKit but it’s missing a lot that is still available in UIKit/AppKit. Something both implementations share is the inability to export or print an image of your map showing any markers or tile overlays.
For my current MacOS app project I’m making use of a lot of overlays and annotations so I’ve elected to use MKMapView via NSViewRepresentable. If you’re not familiar with NSViewRepresentable, it allows using AppKit views in your SwiftUI projects. If you need the (mostly) full features of MapKit you will need to revert to the older AppKit/UIKit implementations.
My app, Cartographer, is a GPS data viewer/editor. It allows you to create maps with paths and locations. You can import or export data from GPX files, a standard GPS exchange format.
Many people just import GPX data into their favorite map device such as an iPhone or Garmin. The paths and waypoints are displayed on a map on the device and you can use the information for navigation.
But what if you want to share your map as an image or even print it for some real old school navigation? Not to mention paper maps don’t need cell service or batteries. This is one of the areas where MapKit falls short.
SwiftUI offers imageRenderer to generate an image from a SwiftUI View. if you’re working with SwiftU’s MapKit implementation, you may think you can just call that and you're all set. Unfortunately imageRenderer won’t work with complex views such as Webviews and MapViews.
If you’re working with older MapKit implementations, as I am, you might try to use some code to just convert your view to an image and export or print that. That will work. Sorta. Depending on how you try to convert your mapView to an image you will get different results all of which fall short.
If your map uses tile overlays to display custom map types, such as topographic maps, you may or may not get an image that includes those overlays. If you are displaying any kind of map Annotations they will not be included in your image.
While this is frustrating the reasoning makes some sense. You will notice that if you zoom in/out on your map that the map and any overlays (tile or others like polylines) will scale to your maps zoom level. Markers will always remain the same size and be anchored to the map locations they are associated with. This is good and the expected behavior.
However the way the MapKit framework achieves this is by storing the Annotations in a separate subview. That subview will not be rendered if you try to convert the mapView to an image.
I want to export the map view as it appears in app. Users expect that and anything less is not worth shipping.
With some digging in Xcode’s debugger I was able to determine the view hierarchy for a MapView.
Using Xcodes View Debugger you can see that a MKMapView has a few container subviews. For reasons unclear to me the method I’m using to generate a map image will include the map overlays but not the markers. I assume this is due to the previously mentioned scaling of the map content.
We can see that there is an MKAnnotationContainerView. This seems like a likely candidate to hold the annotations displayed on our map.
From there we can see the handful of views that build up the marker. At first I thought I would have to manually draw or composite the marker bubble and the associated glyph for each marker onto the map image I was generating. Then I thought if I can just create an image from the entire MKAnnotationContainerView I could composite that over the map image and be done. Turns out, that works.
This is how I accomplished the generation of an image from a map view. It’s a bit of a hack and could break as it relies on grabbing subviews of the map view based on their index. Apple could change or break this at any time but the MapKit framework is pretty mature and I feel somewhat safe shipping this. Most importantly this solution doesn’t make use of any private framework items so it should be fine to ship.
The first piece of the puzzle I’ve been alluding to is creating an image from a view. There are several ways to accomplish this but this is the one I’ve used in my app.
This creates an extension on NSView (you can use these same methods for iOS, just swap in the UIView equivalents) I created the extension on NSView and not MKMapView as the container view for the annotations is a different type and it’s not publicly exposed so Swift will balk if you try to extend that.
This code takes any NSView or subclass and generates a NSImage from it. Passing a MapView will result in an image of the map as it appears in your app but it will be missing any Annotations. As I said earlier, this method will create any overlays including tile overlays. So in my usage, this creates an image with the custom tile overlays and any paths currently in view.
That’s a good start but what about the markers? Since the MKAnnotationContainerView is a subclass of NSView we can just use the same method to generate a second image that contains only the annotations. Luckily for us, the MKAnnotationContainerView is the same size as the underlying mapView so it makes it trivial to composite the marker image over the map image.
This extension on MKMapView will return an image that is an accurate depiction of the current map including all annotations and overlays. It does make assumptions that mapView.subview[1] is the annotation container but that appears to work.
so now a call to
mapView.imageRepresentation()
will return an image that you can then print or export.
No that's not a screenshot, thats an image generated from the map. Nice.
Hope this is helpful to others. Any comments/suggestions for better ways are welcome. Hit me up on Mastodon.