Better coding can transform swift offline maps
Offline maps represent a number of different user challenges. Getting more strategic about coding can alleviate that stress and improve results.In brief
- Swift offline maps can be hard for users to navigate.
- Taking a different coding approach can improve user experiences, keep people engaged and deepen relationships.
How to include offline map elements in your mobile app
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 address that it can be used when a user is offline or only has a limited internet connection.
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 nearly 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’re 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 zoomed out as far as possible and is represented by one tile.
The 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 to 100 kb of space. Since the MyRangeBC app only deals with very specific parts of BC, I didn’t deem it necessary to have all of the tiles for all of BC. That would have resulted in a few GB of data. Instead, I went on and determined the longitude and latitude for 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, since 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: Step 4: Convert point of interest long/lat
Each tile has a unique X, Y and Z value. Z is the zoom level, while X and Y determine the specific tile. For the app to get the correct map tile, you 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 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 (e.g., tile-x-y-z.png).
While we haven’t yet implemented this code in the current version of the MyRangeBC application, I can definitely see the use of it in future versions. I think the downloading of map tiles will be especially very helpful for rangers that are in areas without internet connectivity.
Summary
Making it easier for users to interact with swift offline maps can improve their experience, and keep them engaged. Take a different approach when coding to unlock those possibilities now.