Custom Offline Map Using MapTiler

MapTiler

MapTiler is a fantastic tool for converting raster maps into a format suitable for web applications and mobile devices.

What I am going to show in this article is how easy it is to integrate your own map content inside the MapKit framework.

Tiles and zoom levels

Web/Mobile maps are made up of many small square images called Tiles.

Tiles are 256 × 256 pixels (or more) PNG files where each zoom level is a directory, each column is a subdirectory, and each tile in that column is a file (/zoom/x/y.png).

MapTiler

To get started with this tutorial, first download:
1. Maptiler Free Version: http://www.maptiler.com/download/
2. Example of Map (right click on the image and choose “Save Image as…”):
Map Overlay(or you can save and edit a template map using: http://www.maptiler.org/photoshop-google-maps-overlay-tiles/)

First Steps

Let’s follow these steps:

  1. Open MapTiler and choose “Standard Tiles” optionStandard Tiles
  2. Drag your map image to the view
  3. From the dropdown list, choose “Web Mercator (EPSG:3857)”Web Mercator (EPSG:3857)
  4. Then choose “Assign location visually (Georeferencer)”Map Image
  5. You will get to the screen with 2 maps, now you will need to assign at least 3 control points on the same places on both maps.Control PointControl PointControl PointCoordinate System
  6. Render and export using both formats: “Folder” and “MBTiles”MBTilesRenderView MapPreviewResult

Custom Offline Map Overlay

Once you have your map tiles using the process described above, let’s attach it to your iOS map.

MKOverlay

MKOverlay protocol allows you to represent the whole set of tiles across the map plane and for different zoom levels.

The MKOverlay protocol defines a specific type of annotation that represents both a point and an area on a map. MKOverlay Protocol Reference

Now it’s time to implement your map:

  1. Create a new xcode project (Single View Application)
  2. Add a Map Kit View to your view (Link the view delegate and create a new property MKMapView mapView)Map Kit ViewResizeDelegateProperty
  3. Drag the TILES Folder to your xcode project (Make sure “Copy items if needed” and “Create folder references” is checked)Copy items if neededCreate folder referencesCopy Bundle Resources
  4. Implement the following classes:

TileOverlay.h

#import <MapKit/MapKit.h>

@interface ImageTile : NSObject {
 NSString *imagePath;
 MKMapRect frame;
}

@property (nonatomic, readonly) MKMapRect frame;
@property (nonatomic, readonly) NSString *imagePath;

@end

@interface TileOverlay : NSObject <MKOverlay> {
 NSString *tileBase;
 MKMapRect boundingMapRect;
 NSSet *tilePaths;
}

// Initialize the TileOverlay with a directory structure containing map tile images.
// The directory structure must conform to the output of the gdal2tiles.py utility.
- (id)initWithTileDirectory:(NSString *)tileDirectory;

// Return an array of ImageTile objects for the given MKMapRect and MKZoomScale
- (NSArray *)tilesInMapRect:(MKMapRect)rect zoomScale:(MKZoomScale)scale;

@end

TileOverlay.m

#import "TileOverlay.h"

#define TILE_SIZE 512.0

@interface ImageTile (FileInternal)
- (id)initWithFrame:(MKMapRect)f path:(NSString *)p;
@end

@implementation ImageTile

@synthesize frame, imagePath;

- (id)initWithFrame:(MKMapRect)f path:(NSString *)p
{
 if (self = [super init]) {
 imagePath = [p copy];
 frame = f;
 }
 return self;
}

@end

// Convert an MKZoomScale to a zoom level where level 0 contains 4 512px square tiles,
// which is the convention used by gdal2tiles.py.
static NSInteger zoomScaleToZoomLevel(MKZoomScale scale) {
 double numTilesAt1_0 = MKMapSizeWorld.width / TILE_SIZE;
 NSInteger zoomLevelAt1_0 = log2(numTilesAt1_0); // add 1 because the convention skips a virtual level with 1 tile.
 NSInteger zoomLevel = MAX(0, zoomLevelAt1_0 + floor(log2f(scale) + 0.5));
 return zoomLevel;
}

@implementation TileOverlay

- (id)initWithTileDirectory:(NSString *)tileDirectory
{
 if (self = [super init]) {
 tileBase = [tileDirectory copy];
 // scan tilePath to determine what files are available
 NSFileManager *fileman = [NSFileManager defaultManager];
 NSDirectoryEnumerator *e = [fileman enumeratorAtPath:tileDirectory];
 NSString *path = nil;
 NSMutableSet *pathSet = [[NSMutableSet alloc] init];
 NSInteger minZ = INT_MAX;
 while (path = [e nextObject]) {
 if (NSOrderedSame == [[path pathExtension] caseInsensitiveCompare:@"png"]) {
 NSArray *components = [[path stringByDeletingPathExtension] pathComponents];
 if ([components count] == 3) {
 NSInteger z = [[components objectAtIndex:0] integerValue];
 NSInteger x = [[components objectAtIndex:1] integerValue];
 NSInteger y = [[components objectAtIndex:2] integerValue];
 
 NSString *tileKey = [[NSString alloc] initWithFormat:@"%ld/%ld/%ld", (long)z, (long)x, (long)y];
 
 [pathSet addObject:tileKey];

 
 if (z < minZ)
 minZ = z;
 }
 }
 }
 
 if ([pathSet count] == 0) {
 NSLog(@"Could not locate any tiles at %@", tileDirectory);
 return nil;
 }
 
 // find bounds of base level of tiles to determine boundingMapRect
 
 NSInteger minX = INT_MAX;
 NSInteger minY = INT_MAX;
 NSInteger maxX = 0;
 NSInteger maxY = 0;
 for (NSString *tileKey in pathSet) {
 NSArray *components = [tileKey pathComponents];
 NSInteger z = [[components objectAtIndex:0] integerValue];
 NSInteger x = [[components objectAtIndex:1] integerValue];
 NSInteger y = [[components objectAtIndex:2] integerValue];
 if (z == minZ) {
 minX = MIN(minX, x);
 minY = MIN(minY, y);
 maxX = MAX(maxX, x);
 maxY = MAX(maxY, y);
 } 
 }
 
 NSInteger tilesAtZ = pow(2, minZ);
 double sizeAtZ = tilesAtZ * TILE_SIZE;
 double zoomScaleAtMinZ = sizeAtZ / MKMapSizeWorld.width;
 
 // gdal2tiles convention is that the 0th tile in the y direction
 // is at the bottom. MKMapPoint convention is that the 0th point
 // is in the upper left. So need to flip y to correctly address
 // the tile path.
// NSInteger flippedMinY = labs(minY + 1 - tilesAtZ);
// NSInteger flippedMaxY = labs(maxY + 1 - tilesAtZ);
 
 double x0 = (minX * TILE_SIZE) / zoomScaleAtMinZ;
 double x1 = ((maxX+1) * TILE_SIZE) / zoomScaleAtMinZ;
 double y0 = (minY * TILE_SIZE) / zoomScaleAtMinZ;
 double y1 = ((maxY+1) * TILE_SIZE) / zoomScaleAtMinZ;
 
 boundingMapRect = MKMapRectMake(x0, y0, x1 - x0, y1 - y0);
 
 tilePaths = pathSet;
 }
 return self;
}

- (CLLocationCoordinate2D)coordinate
{
 return MKCoordinateForMapPoint(MKMapPointMake(MKMapRectGetMidX(boundingMapRect),
 MKMapRectGetMidY(boundingMapRect)));
}

- (MKMapRect)boundingMapRect
{
 return boundingMapRect;
}


- (NSArray *)tilesInMapRect:(MKMapRect)rect zoomScale:(MKZoomScale)scale
{
 NSInteger z = zoomScaleToZoomLevel(scale);
 
 // Number of tiles wide or high (but not wide * high)
// NSInteger tilesAtZ = pow(2, z);
 
 NSInteger minX = floor((MKMapRectGetMinX(rect) * scale) / TILE_SIZE);
 NSInteger maxX = floor((MKMapRectGetMaxX(rect) * scale) / TILE_SIZE);
 NSInteger minY = floor((MKMapRectGetMinY(rect) * scale) / TILE_SIZE);
 NSInteger maxY = floor((MKMapRectGetMaxY(rect) * scale) / TILE_SIZE);
 
 NSMutableArray *tiles = nil;
 
 for (NSInteger x = minX; x <= maxX; x++) {
 for (NSInteger y = minY; y <= maxY; y++) {
 // As in initWithTilePath, need to flip y index to match the gdal2tiles.py convention.
// NSInteger flippedY = labs(y + 1 - tilesAtZ);
 
 NSString *tileKey = [[NSString alloc] initWithFormat:@"%ld/%ld/%ld", (long)z, (long)x, (long)y]; /* Not in use flippedY */
 if ([tilePaths containsObject:tileKey]) {
 if (!tiles) {
 tiles = [NSMutableArray array];
 }
 
 MKMapRect frame = MKMapRectMake((double)(x * TILE_SIZE) / scale,
 (double)(y * TILE_SIZE) / scale,
 TILE_SIZE / scale,
 TILE_SIZE / scale);
 
 NSString *path = [[NSString alloc] initWithFormat:@"%@/%@.png", tileBase, tileKey];
 ImageTile *tile = [[ImageTile alloc] initWithFrame:frame path:path];
 [tiles addObject:tile];
 }
 }
 }
 
 return tiles;
}

@end

MKOverlayRenderer

The MKOverlayRenderer class defines the basic behavior associated with all map-based overlays. MKOverlayRenderer Class Reference

TileOverlayView.h

#import <MapKit/MapKit.h>


@interface TileOverlayView : MKOverlayRenderer {
 CGFloat tileAlpha;
}

@property (nonatomic, assign) CGFloat tileAlpha;

- (id)initWithOverlay:(id <MKOverlay>)overlay;

@end

 

TileOverlayView.m

#import "TileOverlayView.h"
#import "TileOverlay.h"

@implementation TileOverlayView

@synthesize tileAlpha;

- (id)initWithOverlay:(id <MKOverlay>)overlay
{
 if (self = [super initWithOverlay:overlay]) {
 tileAlpha = 1.0;
 }
 return self;
}

- (BOOL)canDrawMapRect:(MKMapRect)mapRect
 zoomScale:(MKZoomScale)zoomScale
{
 // Return YES only if there are some tiles in this mapRect and at this zoomScale.
 TileOverlay *tileOverlay = (TileOverlay *)self.overlay;
 NSArray *tilesInRect = [tileOverlay tilesInMapRect:mapRect zoomScale:zoomScale];
 return [tilesInRect count] > 0; 
}

- (void)drawMapRect:(MKMapRect)mapRect
 zoomScale:(MKZoomScale)zoomScale
 inContext:(CGContextRef)context
{
 TileOverlay *tileOverlay = (TileOverlay *)self.overlay;
 
 // Get the list of tile images from the model object for this mapRect. The
 // list may be 1 or more images (but not 0 because canDrawMapRect would have
 // returned NO in that case).
 NSArray *tilesInRect = [tileOverlay tilesInMapRect:mapRect zoomScale:zoomScale];
 
 CGContextSetAlpha(context, tileAlpha);
 
 for (ImageTile *tile in tilesInRect) {
 // For each image tile, draw it in its corresponding MKMapRect frame
 CGRect rect = [self rectForMapRect:tile.frame];
 UIImage *image = [[UIImage alloc] initWithContentsOfFile:tile.imagePath];
 CGContextSaveGState(context);
 CGContextTranslateCTM(context, CGRectGetMinX(rect), CGRectGetMinY(rect));
 CGContextScaleCTM(context, 1/zoomScale, 1/zoomScale);
 CGContextTranslateCTM(context, 0, image.size.height);
 CGContextScaleCTM(context, 1, -1);
 CGContextDrawImage(context, CGRectMake(0, 0, image.size.width, image.size.height), [image CGImage]);
 CGContextRestoreGState(context);
 }
}

@end

TileMapViewController.h

#import <UIKit/UIKit.h>

@interface TileViewController : UIViewController

@end

TileMapViewController.m

#import "TileViewController.h"
#import <MapKit/MapKit.h>
#import "TileOverlay.h"
#import "TileOverlayView.h"

@interface TileViewController ()

@property (strong, nonatomic) IBOutlet MKMapView *map;

@end

@implementation TileViewController

- (void)viewDidLoad {
 [super viewDidLoad];
 // Initialize the TileOverlay with tiles in the application's bundle's resource directory.
 // Any valid tiled image directory structure in there will do.
 NSString *tileDirectory = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"Tiles"];
 
 TileOverlay *overlay = [[TileOverlay alloc] initWithTileDirectory:tileDirectory];
 [self.map addOverlay:overlay];
 
 // zoom in by a factor of two from the rect that contains the bounds
 // because MapKit always backs up to get to an integral zoom level so
 // we need to go in one so that we don't end up backed out beyond the
 // range of the TileOverlay.
 MKMapRect visibleRect = [self.map mapRectThatFits:overlay.boundingMapRect];
 visibleRect.size.width /= 2;
 visibleRect.size.height /= 2;
 visibleRect.origin.x += visibleRect.size.width / 2;
 visibleRect.origin.y += visibleRect.size.height / 2;
 self.map.visibleMapRect = visibleRect;
 
 // Annotations - Examples
 MKPointAnnotation *pinA = [MKPointAnnotation new];
 pinA.coordinate = CLLocationCoordinate2DMake(-37.86957287911382, 175.3535234928131);
 pinA.title = @"A1";
 [self.map addAnnotation:pinA];
 
 MKPointAnnotation *pinB = [MKPointAnnotation new];
 pinB.coordinate = CLLocationCoordinate2DMake(-37.869500888732134, 175.35353422164917);
 pinB.title = @"A2";
 [self.map addAnnotation:pinB];
 
 MKPointAnnotation *pinC = [MKPointAnnotation new];
 pinC.coordinate = CLLocationCoordinate2DMake(-37.869568644387435, 175.3533786535263);
 pinC.title = @"A3";
 [self.map addAnnotation:pinC];
}

#pragma marker - MKMapViewDelegate

- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id<MKAnnotation>)annotation
{
 static NSString *identifier = @"MyAnnotationView";
 
 if ([annotation isKindOfClass:[MKUserLocation class]]) {
 return nil;
 }
 
 MKAnnotationView *view = (id)[mapView dequeueReusableAnnotationViewWithIdentifier:identifier];
 if (view) {
 view.annotation = annotation;
 } else {
 view = [[MKAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:identifier];
 view.canShowCallout = true;
 view.image = [UIImage imageNamed:@"interactive"];
 
 UIButton *disclosure = [UIButton buttonWithType:UIButtonTypeContactAdd];
 [disclosure addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(disclosureTapped:)]];
 view.rightCalloutAccessoryView = disclosure;
 }
 
 return view;
}

- (TileOverlayView *)mapView:(MKMapView *)mapView viewForOverlay:(id <MKOverlay>)overlay
{
 TileOverlayView *view = [[TileOverlayView alloc] initWithOverlay:overlay];
 return view;
}

#pragma marker - Callout

- (IBAction) disclosureTapped:(id)sender
{
 NSLog(@"You tapped the callout button!");
}

@end
Demo Code

Custom Offline Map Using MapTiler

Download the demo code from github:
Custom Offline Map Using MapTiler

References

MapTiler

MKTileOverlay

Custom and Offline Maps Using Overlay Tiles

MKTile​Overlay, MKMap​Snapshotter & MKDirections

The Google Maps API and Custom Overlays

Overlay Images and Overlay Views with MapKit Tutorial

Keep up with my guides and how-tos like this by liking my Facebook Page. If you have any questions about working with offline maps, please join the discussion below!