Offline maps with GeoJSONMap and SpriteKitDSL

Maxim Volgin
3 min readFeb 6, 2019

Why would you want to have a custom offline map?

Let’s name some potential use cases:

  • to draw a plane in SceneKit or ARKit, like in PokemonGo.
  • to draw a map for indoor navigation, when zoom level is high and the outside world is not particularly relevant.
  • in gamification scenarios, when you want to keep (and boast) a map of visited places.
  • when you intend to use the app without the internet connection and you do not need the map of the entire world.

Tools for the job

We shall be using two open-source frameworks, GeoJSONMap and SpriteKitDSL. The first one takes care of parsing GeoJSON — which is a less trivial task than it may appear at first — and provides an interface for building maps in the style of MapKit, so it will be familiar for MapKit users. The latter harnesses the power of Swift lambdas to provide a way of building SpriteKit objects in a hierarchically structured manner, much like mark-up languages do. Both frameworks are available via Carthage.

Ready, set, go

Create a new Xcode project with a SpriteKit game template. Add Cartfile file to the root of the project with following lines in it:

github "maxvol/GeoJSONMap" ~> 0.1.0
github "maxvol/SpriteKitDSL" ~> 0.0.5

In target’s Build Settings->Framework Search Paths add lines:

$(inherited)
$(PROJECT_DIR)/Carthage/Build/iOS

In target Build Phases->New Run Script Phases add shell command /usr/local/bin/carthage copy-frameworksand in Input Files add lines:

$(SRCROOT)/Carthage/Build/iOS/GeoJSONMap.framework
$(SRCROOT)/Carthage/Build/iOS/SpriteKitDSL.framework

After installing the frameworks via Carthage (you might want to follow this guide), in target General->Linked Frameworks and Libraries add:

GeoJSONMap.framework
SpriteKitDSL.framework

Cut to the chase

We start with adding the relevant imports and outlining the high level code, where we need to provide featureCollection acquired from a .geojson file:

mport GeoJSONMap
import SpriteKitDSL
final class ViewController: UIViewController {
let map = GJMap<ViewController>()

override func viewDidLoad() {
super.viewDidLoad()

self.map.delegate = self
self.map.add(featureCollection: /* ... */)

let mapRect = self.map.boundingMapRect
let cgSize = mapRect.size.cgSize
let scene = SKScene(size: cgSize)
for node in self.map.nodes {
scene.addChild(node)
}

/* use `scene` */
}
}

Here’s how to read a .geojson file -

guard let url = Bundle
.main
.url(forResource: "MyFancyMap", withExtension: "json") else {
return
}
guard let jsonData = try? Data(contentsOf: url) else { return }let jsonDecoder = JSONDecoder()let featureCollection: GJFeatureCollection<Properties> = try jsonDecoder
.decode(GJFeatureCollection<Properties>.self, from: jsonData)

But wait, type Properties is not defined anywhere! That’s right, GeoJSON allows any kind of properties, so we need to define one ourselves (obviously, it does not have to be called exactly Properties) to match our particular .geojson file:

public struct Properties: Codable {
let prop0: String
let prop1: Int?
}

Now we can implement the delegate -

extension ViewController: GJMapDelegate {
typealias P = Properties
func map(_ map: GJMap<ViewController>, nodeFor feature: GJFeature<Properties>) -> SKNode? {
let mapRect = self.map.boundingMapRect
let cgSize = mapRect.size.cgSize
switch feature.geometry {
case .point(let coordinate):
let point = MKMapPoint(coordinate)
guard let cgPoint = try? point.cgPoint(from: mapRect, to: cgSize) else { return nil }
let node = SKShapeNode(circleOfRadius: /* ... */)
node.position = cgPoint
/* ... */
return node
case .lineString(let coordinates):
do {
var points = try coordinates.map { try MKMapPoint($0).cgPoint(from: mapRect, to: cgSize) }
let node = SKShapeNode(splinePoints: &points, count: points.count)
/* ... */
return node
} catch {
print(error)
return nil
}
}
}
}

Wait, where is the promised DSL? It is the easiest part left for last. We can redefine our nodes as follows -

case .point(let coordinate):
// skipped for brevity
return SKShapeNode(circleOfRadius: /* ... */).apply {
$0.position = cgPoint
/* set other node parameters on $0 */
}
case .lineString(let coordinates):
// skipped for brevity
return SKShapeNode(splinePoints: &points, count: points.count).apply {
/* set node parameters on $0 */
}

Enjoy!

--

--