Skip to content

Instantly share code, notes, and snippets.

@namannik
Last active April 10, 2024 16:28
Show Gist options
  • Save namannik/3b7c8b69c2d0768d0c2b48d2ed5ff71c to your computer and use it in GitHub Desktop.
Save namannik/3b7c8b69c2d0768d0c2b48d2ed5ff71c to your computer and use it in GitHub Desktop.
MGLMapView+MBTiles

MGLMapView+MBTiles.swift

What it does

It enables an MGLMapView (from the Mapbox Maps iOS SDK) to display MBTiles.

How it works

It adds an extension to MGLMapView for adding MBTiles sources. When an MBTiles source is added to an MGLMapView, it starts a web server within your app and points the map's style to localhost.

Installation

  1. Add MGLMapView+MBTiles.swift (from this Gist) to your app.
  2. Add the GCDWebServer library to your app.
  3. Add the SQLite.swift library to your app.
  4. Add the following to your app's Info.plist:
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSExceptionDomains</key>
        <dict>
            <key>localhost</key>
            <dict>
                <key>NSExceptionAllowsInsecureHTTPLoads</key>
                <true/>
                <key>NSIncludesSubdomains</key>
                <false/>
            </dict>
        </dict>
    </dict> 

Usage

Add a source to mapView
let path = "/path/to/source.mbtiles"
if let source = try? MBTilesSource(filePath: path),
   let index = mapView.style?.layers.count {
    try? mapView.insertMBTilesSource(source, at: index)
}
Remove a source from mapView
mapView.remove(MBTilesSource: source)

Known Limitations

• Only MBTiles that contain raster data sources (jpg, png) are currently supported. Vector sources (i.e. pbf) are not (yet) supported.

Why?

I created this for my app, Forest Maps.

import Foundation
import Mapbox
import SQLite
// MARK: MbtilesSource
enum MBTilesSourceError: Error {
case CouldNotReadFileError
case UnknownFormatError
case UnsupportedFormatError
}
class MBTilesSource: NSObject {
// MBTiles spec, including details about vector tiles:
// https://github.com/mapbox/mbtiles-spec/
// MARK: Properties
var filePath: String
fileprivate var isVector = false
fileprivate var tileSize: Int?
fileprivate var layersJson: String?
fileprivate var attribution: String?
var minZoom: Float?
var maxZoom: Float?
var bounds: MGLCoordinateBounds?
fileprivate var mglTileSource: MGLTileSource?
fileprivate var mglStyleLayer: MGLForegroundStyleLayer?
var isVisible: Bool {
get {
return mglStyleLayer == nil ? false : mglStyleLayer!.isVisible
}
set {
mglStyleLayer?.isVisible = newValue
}
}
private var database: Connection?
private let validRasterFormats = ["jpg", "png"]
private let validVectorFormats = ["pbf", "mvt"]
// MARK: Functions
init(filePath: String) throws {
self.filePath = filePath
super.init()
do {
self.database = try Connection(filePath, readonly: true)
guard let anyTile = try database?.scalar("SELECT tile_data FROM tiles LIMIT 1") as? Blob else {
throw MBTilesSourceError.CouldNotReadFileError
}
let tileData = Data(bytes: anyTile.bytes)
// https://stackoverflow.com/a/42104538
var format: String?
let headerData = [UInt8](tileData)[0]
if headerData == 0x89 {
format = "png"
} else if headerData == 0xFF {
format = "jpg"
} else {
format = getMetadata(fieldName: "format")
}
if format == nil {
throw MBTilesSourceError.UnknownFormatError
} else if validRasterFormats.contains(format!) {
isVector = false
} else if validVectorFormats.contains(format!) {
isVector = true
} else {
throw MBTilesSourceError.UnsupportedFormatError
}
if let tileImage = UIImage(data: tileData) {
let screenScale = UIScreen.main.scale > 1 ? 2 : 1
self.tileSize = Int(tileImage.size.height / CGFloat(screenScale))
}
if let minString = getMetadata(fieldName: "minzoom") {
minZoom = Float(minString)
}
if let maxString = getMetadata(fieldName: "maxzoom") {
maxZoom = Float(maxString)
}
if let boundsString = getMetadata(fieldName: "bounds") {
let coordinates = boundsString.split(separator: ",")
if let west = CLLocationDegrees(coordinates[0]),
let south = CLLocationDegrees(coordinates[1]),
let east = CLLocationDegrees(coordinates[2]),
let north = CLLocationDegrees(coordinates[3]) {
bounds = MGLCoordinateBoundsMake(CLLocationCoordinate2DMake(south, west),
CLLocationCoordinate2DMake(north, east))
}
}
attribution = getMetadata(fieldName: "attribution")
layersJson = getMetadata(fieldName: "json")
} catch {
throw MBTilesSourceError.CouldNotReadFileError
}
}
deinit {
database = nil
}
private func getMetadata(fieldName: String) -> String? {
let query = "SELECT value FROM metadata WHERE name=\"\(fieldName)\""
if let binding = try? database?.scalar(query) {
return binding as? String
}
return nil
}
fileprivate func getTile(x: Int, y: Int, z: Int) -> Data? {
let query = "SELECT tile_data FROM tiles WHERE tile_column=\(x) AND tile_row=\(y) AND zoom_level=\(z)"
if let binding = try? database?.scalar(query),
let blob = binding as? Blob {
return Data(bytes: blob.bytes)
}
return nil
}
}
// MARK: - MGLMapView Extension
extension MGLMapView {
func insertMBTilesSource(_ source: MBTilesSource, at index: UInt, tileSize: Int? = nil) throws {
remove(MBTilesSource: source)
let port: UInt = 54321
MbtilesServer.shared.start(port: port)
MbtilesServer.shared.sources[source.filePath] = source
let filePath = source.filePath as NSString
guard let escapedPath = filePath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlPathAllowed) else {
return
}
let id = filePath.lastPathComponent
let urlTemplate = "http://localhost:\(port)\(escapedPath)?x={x}&y={y}&z={z}"
var options = [MGLTileSourceOption : Any]()
options[MGLTileSourceOption.tileCoordinateSystem] = MGLTileCoordinateSystem.TMS.rawValue
options[MGLTileSourceOption.tileSize] = tileSize ?? source.tileSize
options[MGLTileSourceOption.minimumZoomLevel] = source.minZoom
options[MGLTileSourceOption.maximumZoomLevel] = source.maxZoom
if let bounds = source.bounds {
options[MGLTileSourceOption.coordinateBounds] = NSValue(mglCoordinateBounds: bounds)
}
if let attributionString = source.attribution {
options[MGLTileSourceOption.attributionInfos] = [MGLAttributionInfo(title: NSAttributedString(string: attributionString) , url: nil)]
}
if source.isVector {
// Only raster data (jpg, png) is currently supported.
// Raster data (i.e. pbf) is not (yet) supported.
throw MBTilesSourceError.UnsupportedFormatError
} else {
source.mglTileSource = MGLRasterTileSource(identifier: id, tileURLTemplates: [urlTemplate], options: options)
source.mglStyleLayer = MGLRasterStyleLayer(identifier: id, source: source.mglTileSource!)
}
if source.minZoom != nil {
source.mglStyleLayer!.minimumZoomLevel = source.minZoom!
}
if source.maxZoom != nil {
source.mglStyleLayer!.maximumZoomLevel = source.maxZoom!
}
DispatchQueue.main.async { [weak self] in
self?.style?.addSource(source.mglTileSource!)
self?.style?.insertLayer(source.mglStyleLayer!, at: index)
}
}
func remove(MBTilesSource source: MBTilesSource) {
let filePath = source.filePath
let id = (filePath as NSString).lastPathComponent
DispatchQueue.main.async { [weak self] in
if let layer = self?.style?.layer(withIdentifier: id) {
self?.style?.removeLayer(layer)
}
if let source = self?.style?.source(withIdentifier: id) {
self?.style?.removeSource(source)
}
}
MbtilesServer.shared.sources[filePath] = nil
}
}
// MARK: - Server
fileprivate class MbtilesServer: NSObject {
// MARK: Properties
static let shared = MbtilesServer()
var sources = [String : MBTilesSource]()
private var webServer: GCDWebServer? = nil
// MARK: Functions
func start(port: UInt) {
guard Thread.isMainThread else {
DispatchQueue.main.sync { [weak self] in
self?.start(port: port)
}
return
}
if webServer == nil {
webServer = GCDWebServer()
}
guard !webServer!.isRunning else {
return
}
GCDWebServer.setLogLevel(3)
webServer?.addDefaultHandler(forMethod: "GET", request: GCDWebServerRequest.self, processBlock: { [weak self] (request: GCDWebServerRequest) -> GCDWebServerResponse? in
return self?.handleGetRequest(request)
})
webServer?.start(withPort: port, bonjourName: nil)
}
func stop() {
guard Thread.isMainThread else {
DispatchQueue.main.sync { [weak self] in
self?.stop()
}
return
}
webServer?.stop()
webServer?.removeAllHandlers()
}
private func handleGetRequest(_ request: GCDWebServerRequest) -> GCDWebServerResponse? {
if (request.path as NSString).pathExtension == "mbtiles",
let source = sources[request.path],
let query = request.query,
let xString = query["x"] as? String,
let yString = query["y"] as? String,
let zString = query["z"] as? String,
let x = Int(xString),
let y = Int(yString),
let z = Int(zString),
let tileData = source.getTile(x: x, y: y, z: z) {
return GCDWebServerDataResponse(data: tileData, contentType: "")
} else {
return GCDWebServerResponse(statusCode: 404)
}
}
}
@songyuyang0918
Copy link

@StefanoBiasu @namannik Who can share a class file that supports swift4.2?
Swift is my pain point,
My ios version no longer supports swift3.0

@namannik
Copy link
Author

Are there any specific errors preventing it from compiling?

@songyuyang0918
Copy link

Has been resolved if compiled using swift4.2
Init is preceded by @objc public
Add @objc before func insertMBTilesSource
Thank you very much!

@wiliam-toney
Copy link

I am trying this solution to show mbtiles on my iPad app.
GCDWebServer started the local server correctly but iPad app doesn't trigger this url
"http://localhost:\(port)\(escapedPath)?x={x}&y={y}&z={z}"
Which code line must trigger this url?
Actually self?.style?.insertLayer(source.mglStyleLayer!, at: index) make the app crashes so I changed this line as follows
self?.style?.insertLayer(source.mglStyleLayer!, at: 0)
Then app doesn't crashed but mbtiles are not showing up. Is this why the localhost url doesn't triggered?
I am trying to show mbtiles for several days but still no luck.
I would be appreciate any help. Thanks in advance,

@wiliam-toney
Copy link

I just replaced the mbtiles with another one and I see the local url are triggering successfully.
Some mbtiles are not working and some mbtiles are working properly. I don't know why.
I am sure all mbtiles are correct and if I open the mbtiles with a tool like mbtiles viewer, it shows the mbtiles successfully.
Is there any criteria on mbtiles that can work on iOS? I am testing on iOS13.1.2.

@namannik
Copy link
Author

Hi, @danielsgit. Make sure you're calling insertMBTilesSource from either mapView(_ mapView: MGLMapView, didFinishLoading style: MGLStyle) or mapViewDidFinishLoadingMap(_ mapView: MGLMapView) inside your MGLMapViewDelegate. For example:

func mapView(_ mapView: MGLMapView, didFinishLoading style: MGLStyle) {
    let path = "/path/to/source.mbtiles"
    if let source = try? MBTilesSource(filePath: path),
        let index = mapView.style?.layers.count {
        try? mapView.insertMBTilesSource(source, at: index)
    }
}

@songyuyang0918
Copy link

songyuyang0918 commented Nov 28, 2019

hi,@namannik
If I have a lot of mbtiles files, using the current method, which can only be stacked layer by layer, would you consider updating a method that can read more than one mbtiles?

@songyuyang0918
Copy link

This error occurred recently while swiping the map:
[ERROR] ERROR while writing to socket 65: Broken pipe (32)
I saw this log on the real machine

@namannik
Copy link
Author

@songyuyang0918, using the MGLMapView+MBTiles extension, you can add as many MBTiles layers as you'd like. Just be sure to use the appropriate index when calling insertMBTilesSource to ensure the tiles are shown in the order you'd like.

I'm not sure what would be the cause of the broken pipe error you're seeing.

@songyuyang0918
Copy link

hi @namannik
About Mapbox initialization. If I'm using offline, I also have to have a custom white space source and then add it to styleURL?
I wonder how you handled it?

@namannik
Copy link
Author

namannik commented Sep 18, 2020

@songyuyang0918 My app uses a hybrid of an online tile sours and multiple offline tile source. I'm not sure what you mean about "white space source".

@songyuyang0918
Copy link

songyuyang0918 commented Oct 25, 2020

@ namannik
Forgive me for not describing it clearly, I just want to show the source of the block multiple offline tile source.
But MapBox requires that I have the online tile sours to use the offline tile source.
Do you have any good ideas?

@namannik
Copy link
Author

My app works with a styleURL that references a style.json file contained locally within the app and tiles hosted online. The user can download tiles for offline use if they want. I'm not aware that having an online tile source is a requirement of MapBox. As far as I know, you can set the styleURL to get tiles from anywhere, including locally on the device.

@songyuyang0918
Copy link

songyuyang0918 commented Oct 26, 2020

StyleUrl can indeed be accessed from a.JSON file or an online hosting tile.
Because I'm using the Mapbox-based extended offline function MBTilesSource that you developed.
If I only show the offline map, then only show MBTilesSource without showing the styleUrl?
In fact, if styleUrl = nil, Then MBTilesSource will not work.

@hjhimanshu01
Copy link

hey, any suggestions on how to implement this approach with Mapbox V10.10.0?

@folsze
Copy link

folsze commented Apr 10, 2024

@hjhimanshu01 @namannik

Do you know whether this is possible with https://docs.mapbox.com/mapbox-gl-js/api/ for hybrid web wrapper native apps?

If not:
I am thinking of developing a plugin. How hard would that be, to get that to work? What is standing in the way currently?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment