How to include offline map elements in your mobile app

Creating_Offline_Maps_ (MyRangeBC).png

Author: Amir Shayegh, iOS Developer

I recently worked on modernizing the BC range program. It allocates and administers hay cutting and grazing agreements on Crown rangeland. As part of this project we developed a set of apps for iPad and web. The app allows users to file their Range Use Plans from within the app.

One of the main goals for the iPad application was to ensure that it can be used when a user is offline or only has a limited internet connection.

You can read the full case study about it here.

One of the things we explored during this project, was the potential of incorporating an offline map feature. While it didn’t make it into the current version I thought it’d be interesting to share some of the research I did while looking into this feature.

Creating Offline Maps

Trying to find any sort of information on creating an offline map was near impossible. Especially when you want to do it in an open source environment and without a paid provider

Here’s what I learned:

Maps, whether Google, Apple, Bing, or OpenStreetMap, use the same standardization. Each level of the map is divided into tiles. So when you are zoomed all the way out the map consists of one tile. Each time you zoom in each tile gets divided into quadrants.

This map is as far zoomed out as possible and is represented by one tile.

This next map has been zoomed in four times and you need a lot more tiles to represent this area:

I did my research for developing an iOS application, but you can find similar Android tools and update the code to get the same result for an Android application.

Step 1: Add MapKit

Using a tool such as Apple’s Xcode, add a MapKit element as you normally would when developing an iOS application.

Step 2: Get Map Tiles

Override the renderer to get the tiles from OpenStreetMaps:

 
`viewController.swift`
```func setupTileRenderer() {
    let overlay = CustomOverlay();
    overlay.canReplaceMapContent = true
    mapView.addOverlay(overlay, level: .aboveLabels)
    tileRenderer = MKTileOverlayRenderer(tileOverlay: overlay)
}```


`TileMaster.swift`
```func downloadTile(for path: MKTileOverlayPath, then: @escaping (_ success: Bool)-> Void) {
        if let r = Reachability(), r.connection == .none {
            return then(false)
        }
        let queue = DispatchQueue(label: "tileQue", qos: .background, attributes: .concurrent)
        guard let url = openSteetMapURL(for: path) else {
            return then(false)
        }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.timeoutInterval = 3
        Alamofire.request(request).responseData(queue: queue) { (response) in
            switch response.result {
            case .success(_):
                if let data = response.data {
                    do {
                        try data.write(to: self.localPath(for: self.fileName(for: path)))
                        return then(true)
                    } catch {
                        return then(false)
                    }
                } else {
                    return then(false)
                }
            case .failure(_):
                return then(false)
            }
        }
}
    
/**
    Find local path for file
*/
func localPath(for name: String) -> URL {
    return documentDirectory().appendingPathComponent("\(name)")
}

/**
    File names for locally stored tiles :
    Tile-z-x-y.png
*/ 
func fileName(for path: MKTileOverlayPath) -> String {
    return "Tile-\(path.z)-\(path.x)-\(path.y).png"
}```
 

Step 3: Determine Points of Interest

Each tile takes up about 80 - 100 kb of space. Since the MyRangeBC only deals with very specific parts of BC, I didn’t deem it necessary to have all of the tiles for all of BC. This would result in a few GB of data. Instead, I went on and determined the longitude and latitude for our points of interests only.

As part of this I also decided that the MyRangeBC application will only need access to certain tiles that are not fully zoomed in as the use case is mostly for pastures and such. Essentially, we won’t need to zoom in as far as street level for our points of interest.

Step 4: Convert Point of Interest Long/Lat

Each tile has a unique X, Y, and Z value. Z is the zoom level and X and Y determine the specific tile. In order for the app to get the correct map tile, you will need to use the following code to convert the longitude and latitude data into the X, Y, Z schema.

 
`TileMaster.swift`
```func convert(lat: Double, lon: Double, zoom: Int) -> MKTileOverlayPath {
    // Scale factor used to create MKTileOverlayPath object.
    let scaleFactor: CGFloat = 2.0

    // Holders for X Y
    var x: Int = 0
    var y: Int = 0

    let n = pow(2, Double(zoom))
    x = Int(n * ((lon + 180) / 360))
    y = Int(n * (1 - (log(tan(lat.degreesToRadians) + (1/cos(lat.degreesToRadians))) / Double.pi)) / 2)

    return MKTileOverlayPath(x: x, y: y, z: zoom, contentScaleFactor: scaleFactor)
}```
 

If you know how many tiles you need ahead of time, you can easily figure out how much space your offline map will require. Simply take the total number of tiles you require and multiply it by 100KB.

Step 5: Download/Cache Map Tiles

Next, you will want to get the map tiles so that they are available when the app is launched.

 
`CustomOverlay.swift`
```import Foundation
import UIKit
import MapKit

// custom map overlay
class CustomOverlay: MKTileOverlay {
    /*
     MKTileOverlay has a function url(forTilePath path) that returns a URL
     for the given.
     We can override this function to return a different URL for the Path.
        - Path has X Y Z values - default way of identifying a tile
        - We options for returning a URL the Tile:
            - Local URL to the PNG for the tile
            - Remove URL for a png for the tile.
     Here is how we use is:
     - If we have a stored tile for the XYZ Path, return the path to local storage.
     - if we don't have a stored tile for the xyz Path, return an external URL that does.
     
     Here we can also download and store the tile for the XYZ path that we DON'T have stored,
     and cache visited map tiles.
     */
    
    // grabs the tile for the current x y z
    override func url(forTilePath path: MKTileOverlayPath) -> URL {
        if TileMaster.shared.tileExistsLocally(for: path) {
            // return local URL
            return TileMaster.shared.localPath(for: TileMaster.shared.fileName(for: path))
        } else {
            // Otherwise download and save
            TileMaster.shared.downloadTile(for: path) { (success) in
            }
            // If you're downloading and saving, then you could also just pass the url to renderer
            let tileUrl = "https://tile.openstreetmap.org/\(path.z)/\(path.x)/\(path.y).png"
            return URL(string: tileUrl)!
        }
    }
}```
 

You can also add code that checks whether the map tile has previously been downloaded.

 
`TileMaster.swift`
```func tileExistsLocally(for tilePath: MKTileOverlayPath) -> Bool {
        let path = localPath(for: fileName(for: tilePath))
        let filePath = path.path
        let fileManager = FileManager.default
        return fileManager.fileExists(atPath: filePath)
    }```
 

Make sure to store the png map tiles with a specific naming pattern so you can easily fetch them in step 2 (i.e.: tile-x-y-z.png). Tiles for OpenStreetMap can be fetched with this url: https://tile.openstreetmap.org/z/x/y.png - replacing z x y.

While we haven’t implemented this code in the current version of the MyRangeBC application yet, I can definitely see the use of it in future versions. I think especially the downloading of map tiles will be very helpful for rangers that are in areas without internet.