MKTileOverlay,MKMapSnapshotter &MKDirections
Unless you work with MKMap
on a regular basis, the last you may have heard about the current state of cartography on iOS may not have been under the cheeriest of circumstances. Even now, years after the ire of armchair usability experts has moved on to iOS 7’s distinct “look and feel”, the phrase “Apple Maps” still does not inspire confidence in the average developer.
Therefore, it may come as a surprise maps on iOS have gotten quite a bit better in the intervening releases. Quite good, in fact—especially with the new mapping APIs introduced in iOS 7. These new APIs not only expose the advanced presentational functionality seen in Maps, but provide workarounds for MapKit’s limitations.
This week on NSHipster, we’ll introduce MKTile
, MKMap
, and MKDirections
: three new MapKit APIs introduced in iOS 7 that unlock a new world of possibilities.
MKTileOverlay
Don’t like the default Apple Maps tiles? MKTile
allows you to seamlessly swap out to another tile set in just a few lines of code.
Just like OpenStreetMap and Google Maps, MKTileOverlay uses spherical mercator projection (EPSG:3857).
Setting Custom Map View Tile Overlay
let template = "http://tile.openstreetmap.org/{z}/{x}/{y}.png"
let overlay = MKTile Overlay(URLTemplate: template)
overlay.can Replace Map Content = true
map View.add Overlay(overlay, level: .Above Labels)
static NSString * const template = @"http://tile.openstreetmap.org/{z}/{x}/{y}.png";
MKTile Overlay *overlay = [[MKTile Overlay alloc] init With URLTemplate:template];
overlay.can Replace Map Content = YES;
[self.map View add Overlay:overlay
level:MKOverlay Level Above Labels];
MKTileOverlay is initialized with a URL template string, with the x
& y
tile coordinates within the specified zoom level. MapBox has a great explanation for this scheme is used to generate tiles:
Each tile has a z coordinate describing its zoom level and x and y coordinates describing its position within a square grid for that zoom level. Hence, the very first tile in the web map system is at 0/0/0.
0/0/0 |
Zoom level 0 covers the entire globe. The very next zoom level divides z0 into four equal squares such that 1/0/0 and 1/1/0 cover the northern hemisphere while 1/0/1 and 1/1/1 cover the southern hemisphere.
1/0/0 | 1/1/0 |
1/0/1 | 1/1/1 |
Zoom levels are related to each other by powers of four -
z0
contains 1 tile,z1
contains 4 tiles,z2
contains 16, and so on. Because of this exponential relationship the amount of detail increases at every zoom level but so does the amount of bandwidth and storage required to serve up tiles. For example, a map atz15
– about when city building footprints first become visible – requires about 1.1 billion tiles to cover the entire world. Atz17
, just two zoom levels greater, the world requires 17 billion tiles.
After setting can
to YES
, the overlay is added to the MKMap
.
In the map view’s delegate, map
is implemented simply to return a new MKTile
instance when called for the MKTile
overlay.
// MARK: MKMap View Delegate
func map View(map View: MKMap View, renderer For Overlay overlay: MKOverlay) -> MKOverlay Renderer {
guard let tile Overlay = overlay as? MKTile Overlay else {
return MKOverlay Renderer()
}
return MKTile Overlay Renderer(tile Overlay: tile Overlay)
}
#pragma mark - MKMap View Delegate
- (MKOverlay Renderer *)map View:(MKMap View *)map View
renderer For Overlay:(id <MKOverlay>)overlay
{
if ([overlay is Kind Of Class:[MKTile Overlay class]]) {
return [[MKTile Overlay Renderer alloc] init With Tile Overlay:overlay];
}
return nil;
}
Speaking of MapBox, Justin R. Miller maintains MBXMapKit, a MapBox-enabled drop-in replacement for
MKMap
. It’s the easiest way to get up-and-running with this world-class mapping service, and highly recommended for anyone looking to make an impact with maps in their next release.View
Implementing Custom Behavior with MKTileOverlay Subclass
If you need to accommodate a different tile coordinate scheme with your server, or want to add in-memory or offline caching, this can be done by subclassing MKTile
and overriding -URLFor
and -load
:
class MKHipster Tile Overlay : MKTile Overlay {
let cache = NSCache()
let operation Queue = NSOperation Queue()
override func URLFor Tile Path(path: MKTile Overlay Path) -> NSURL {
return NSURL(string: String(format: "http://tile.example.com/%d/%d/%d", path.z, path.x, path.y))!
}
override func load Tile At Path(path: MKTile Overlay Path, result: (NSData?, NSError?) -> Void) {
let url = URLFor Tile Path(path)
if let cached Data = cache.object For Key(url) as? NSData {
result(cached Data, nil)
} else {
let request = NSURLRequest(URL: url)
NSURLConnection.send Asynchronous Request(request, queue: operation Queue) {
[weak self]
response, data, error in
if let data = data {
self?.cache.set Object(data, for Key: url)
}
result(data, error)
}
}
}
}
@interface XXTile Overlay : MKTile Overlay
@property NSCache *cache;
@property NSOperation Queue *operation Queue;
@end
@implementation XXTile Overlay
- (NSURL *)URLFor Tile Path:(MKTile Overlay Path)path {
return [NSURL URLWith String:[NSString string With Format:@"http://tile.example.com/%d/%d/%d", path.z, path.x, path.y]];
}
- (void)load Tile At Path:(MKTile Overlay Path)path
result:(void (^)(NSData *data, NSError *error))result
{
if (!result) {
return;
}
NSData *cached Data = [self.cache object For Key:[self URLFor Tile Path:path]];
if (cached Data) {
result(cached Data, nil);
} else {
NSURLRequest *request = [NSURLRequest request With URL:[self URLFor Tile Path:path]];
[NSURLConnection send Asynchronous Request:request queue:self.operation Queue completion Handler:^(NSURLResponse *response, NSData *data, NSError *connection Error) {
result(data, connection Error);
}];
}
}
@end
MKMapSnapshotter
Another addition to iOS 7 was MKMap
, which formalizes the process of creating an image representation of a map view. Previously, this would involve playing fast and loose with the UIGraphics
, but now images can reliably be created for any particular region and perspective.
See WWDC 2013 Session 309: “Putting Map Kit in Perspective” for additional information on how and when to use
MKMap
.Snapshotter
Creating a Map View Snapshot
let options = MKMap Snapshot Options()
options.region = map View.region
options.size = map View.frame.size
options.scale = UIScreen.main Screen().scale
let file URL = NSURL(file URLWith Path: "path/to/snapshot.png")
let snapshotter = MKMap Snapshotter(options: options)
snapshotter.start With Completion Handler { snapshot, error in
guard let snapshot = snapshot else {
print("Snapshot error: \(error)")
return
}
let data = UIImage PNGRepresentation(snapshot.image)
data?.write To URL(file URL, atomically: true)
}
MKMap Snapshot Options *options = [[MKMap Snapshot Options alloc] init];
options.region = self.map View.region;
options.size = self.map View.frame.size;
options.scale = [[UIScreen main Screen] scale];
NSURL *file URL = [NSURL file URLWith Path:@"path/to/snapshot.png"];
MKMap Snapshotter *snapshotter = [[MKMap Snapshotter alloc] init With Options:options];
[snapshotter start With Completion Handler:^(MKMap Snapshot *snapshot, NSError *error) {
if (error) {
NSLog(@"[Error] %@", error);
return;
}
UIImage *image = snapshot.image;
NSData *data = UIImage PNGRepresentation(image);
[data write To URL:file URL atomically:YES];
}];
First, an MKMap
object is created, which is used to specify the region, size, scale, and camera used to render the map image.
Then, these options are passed to a new MKMap
instance, which asynchronously creates an image with -start
. In this example, a PNG representation of the image is written to disk.
Drawing Annotations on Map View Snapshot
However, this only draws the map for the specified region; annotations are rendered separately.
Including annotations—or indeed, any additional information to the map snapshot—can be done by dropping down into Core Graphics:
snapshotter.start With Queue(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { snapshot, error in
guard let snapshot = snapshot else {
print("Snapshot error: \(error)")
fatal Error()
}
let pin = MKPin Annotation View(annotation: nil, reuse Identifier: nil)
let image = snapshot.image
UIGraphics Begin Image Context With Options(image.size, true, image.scale)
image.draw At Point(CGPoint.zero)
let visible Rect = CGRect(origin: CGPoint.zero, size: image.size)
for annotation in map View.annotations {
var point = snapshot.point For Coordinate(annotation.coordinate)
if visible Rect.contains(point) {
point.x = point.x + pin.center Offset.x - (pin.bounds.size.width / 2)
point.y = point.y + pin.center Offset.y - (pin.bounds.size.height / 2)
pin.image?.draw At Point(point)
}
}
let composite Image = UIGraphics Get Image From Current Image Context()
UIGraphics End Image Context()
let data = UIImage PNGRepresentation(composite Image)
data?.write To URL(file URL, atomically: true)
}
[snapshotter start With Queue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)
completion Handler:^(MKMap Snapshot *snapshot, NSError *error) {
if (error) {
NSLog(@"[Error] %@", error);
return;
}
MKAnnotation View *pin = [[MKPin Annotation View alloc] init With Annotation:nil reuse Identifier:nil];
UIImage *image = snapshot.image;
UIGraphics Begin Image Context With Options(image.size, YES, image.scale);
{
[image draw At Point:CGPoint Make(0.0f, 0.0f)];
CGRect rect = CGRect Make(0.0f, 0.0f, image.size.width, image.size.height);
for (id <MKAnnotation> annotation in self.map View.annotations) {
CGPoint point = [snapshot point For Coordinate:annotation.coordinate];
if (CGRect Contains Point(rect, point)) {
point.x = point.x + pin.center Offset.x -
(pin.bounds.size.width / 2.0f);
point.y = point.y + pin.center Offset.y -
(pin.bounds.size.height / 2.0f);
[pin.image draw At Point:point];
}
}
UIImage *composite Image = UIGraphics Get Image From Current Image Context();
NSData *data = UIImage PNGRepresentation(composite Image);
[data write To URL:file URL atomically:YES];
}
UIGraphics End Image Context();
}];
MKDirections
The final iOS 7 addition to MapKit that we’ll discuss is MKDirections
.
MKDirections
’ spiritual predecessor (of sorts),MKLocal
was discussed in a previous NSHipster articleSearch
As its name implies, MKDirections
fetches routes between two waypoints. A MKDirections
object is initialized with a source
and destination
, and is then passed into an MKDirections
object, which can calculate several possible routes and estimated travel times.
It does so asynchronously, with calculate
, which returns either an MKDirections
object or an NSError
describing why the directions request failed. An MKDirections
object contains an array of routes
: MKRoute
objects with an array of MKRoute
steps
objects, a polyline shape that can be drawn on the map, and other information like estimated travel distance and any travel advisories in effect.
Building on the previous example, here is how MKDirections
might be used to create an array of images representing each step in a calculated route between two points (which might then be pasted into an email or cached on disk):
Getting Snapshots for each Step of Directions on a Map View
let request = MKDirections Request()
request.source = MKMap Item.map Item For Current Location()
request.destination = MKMap Item(...)
let directions = MKDirections(request: request)
directions.calculate Directions With Completion Handler { response, error in
guard let response = response else {
print("Directions error: \(error)")
return
}
step Images From Directions Response(response) { step Images in
step Images.first
print(step Images)
}
}
func step Images From Directions Response(response: MKDirections Response, completion Handler: ([UIImage]) -> Void) {
guard let route = response.routes.first else {
completion Handler([])
return
}
var step Images: [UIImage?] = Array(count: route.steps.count, repeated Value: nil)
var step Image Count = 0
for (index, step) in route.steps.enumerate() {
let snapshotter = MKMap Snapshotter(options: options)
snapshotter.start With Completion Handler { snapshot, error in
++step Image Count
guard let snapshot = snapshot else {
print("Snapshot error: \(error)")
return
}
let image = snapshot.image
UIGraphics Begin Image Context With Options(image.size, true, image.scale)
image.draw At Point(CGPoint.zero)
// draw the path
guard let c = UIGraphics Get Current Context() else { return }
CGContext Set Stroke Color With Color(c, UIColor.blue Color().CGColor)
CGContext Set Line Width(c, 3)
CGContext Begin Path(c)
var coordinates: Unsafe Mutable Pointer<CLLocation Coordinate2D> = Unsafe Mutable Pointer.alloc(step.polyline.point Count)
defer { coordinates.dealloc(step.polyline.point Count) }
step.polyline.get Coordinates(coordinates, range: NSRange(location: 0, length: step.polyline.point Count))
for i in 0 ..< step.polyline.point Count {
let p = snapshot.point For Coordinate(coordinates[i])
if i == 0 {
CGContext Move To Point(c, p.x, p.y)
} else {
CGContext Add Line To Point(c, p.x, p.y)
}
}
CGContext Stroke Path(c)
// add the start and end points
let visible Rect = CGRect(origin: CGPoint.zero, size: image.size)
for map Item in [response.source, response.destination]
where map Item.placemark.location != nil {
var point = snapshot.point For Coordinate(map Item.placemark.location!.coordinate)
if visible Rect.contains(point) {
let pin = MKPin Annotation View(annotation: nil, reuse Identifier: nil)
pin.pin Tint Color = map Item.is Equal(response.source) ?
MKPin Annotation View.green Pin Color() : MKPin Annotation View.red Pin Color()
point.x = point.x + pin.center Offset.x - (pin.bounds.size.width / 2)
point.y = point.y + pin.center Offset.y - (pin.bounds.size.height / 2)
pin.image?.draw At Point(point)
}
}
let step Image = UIGraphics Get Image From Current Image Context()
UIGraphics End Image Context()
step Images[index] = step Image
if step Image Count == step Images.count {
completion Handler(step Images.flat Map({ $0 }))
}
}
}
}
NSMutable Array *mutable Step Images = [NSMutable Array array];
MKDirections Request *request = [[MKDirections Request alloc] init];
request.source = [MKMap Item map Item For Current Location];
request.destination = ...;
MKDirections *directions = [[MKDirections alloc] init With Request:request];
[directions calculate Directions With Completion Handler:^(MKDirections Response *response, NSError *error) {
if (error) {
NSLog(@"[Error] %@", error);
return;
}
MKRoute *route = [response.routes first Object];
for (MKRoute Step *step in route.steps) {
[snapshotter start With Completion Handler:^(MKMap Snapshot *snapshot, NSError *error) {
if (error) {
NSLog(@"[Error] %@", error);
return;
}
UIImage *image = snapshot.image;
UIGraphics Begin Image Context With Options(image.size, YES, image.scale);
{
[image draw At Point:CGPoint Make(0.0f, 0.0f)];
CGContext Ref c = UIGraphics Get Current Context();
MKPolyline Renderer *polyline Renderer = [[MKPolyline Renderer alloc] init With Polyline:step.polyline];
if (polyline Renderer.path) {
[polyline Renderer apply Stroke Properties To Context:c at Zoom Scale:1.0f];
CGContext Add Path(c, polyline Renderer.path);
CGContext Stroke Path(c);
}
CGRect rect = CGRect Make(0.0f, 0.0f, image.size.width, image.size.height);
for (MKMap Item *map Item in @[response.source, response.destination]) {
CGPoint point = [snapshot point For Coordinate:map Item.placemark.location.coordinate];
if (CGRect Contains Point(rect, point)) {
MKPin Annotation View *pin = [[MKPin Annotation View alloc] init With Annotation:nil reuse Identifier:nil];
pin.pin Color = [map Item is Equal:response.source] ? MKPin Annotation Color Green : MKPin Annotation Color Red;
point.x = point.x + pin.center Offset.x -
(pin.bounds.size.width / 2.0f);
point.y = point.y + pin.center Offset.y -
(pin.bounds.size.height / 2.0f);
[pin.image draw At Point:point];
}
}
UIImage *step Image = UIGraphics Get Image From Current Image Context();
[mutable Step Images add Object:step Image];
}
UIGraphics End Image Context();
}];
}
}];
As the tools used to map the world around us become increasingly sophisticated and ubiquitous, we become ever more capable of uncovering and communicating connections we create between ideas and the spaces they inhabit. With the introduction of several new MapKit APIs, iOS 7 took great strides to expand on what’s possible. Although (perhaps unfairly) overshadowed by the mistakes of the past, MapKit is, and remains an extremely capable framework, worthy of further investigation.