Offline maps with GeoJSONMap and SpriteKitDSL
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-frameworks
and 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 SpriteKitDSLfinal 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 = Propertiesfunc 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!