I am creating a travel app in swiftui where users can keep track of which countries they have visited. I want to do this by allowing users to select which countries they have visited from a list and then display a svg of a world map that fills the visited countries blue. Here is an example: app example
Below is an example of the svg file. Every path represents a country and has an id. I want to be able to find a path by it's id and change the color of it to blue. It's pretty easy to do this in javascript but i'm having great trouble finding any documentation online for swift.
<path d="m 479.68275,331.6274 -0.077,0.025 -0.258,0.155 -0.147,0.054 -0.134,0.027 -0.105,-0.011 -0.058,-0.091 0.006,-0.139 -0.024,-0.124 -0.02,-0.067 0.038,-0.181 0.086,-0.097 0.119,-0.08 0.188,0.029 0.398,0.116 0.083,0.109 10e-4,0.072 -0.073,0.119 z" title="Andorra" id="AD" />
I am using SVGKit (https://github.com/SVGKit/SVGKit) to import the svg into my swiftui view. Below is the code that I already have to simple display the svg in my view.
My SVGImageView struct:
struct SVGImageView: View {
let svgImage: SVGKImage
init(svgImage: SVGKImage) {
self.svgImage = svgImage
}
var body: some View {
Image(uiImage: svgImage.uiImage)
.resizable()
.scaledToFit()
}
}
And inside the swiftui view:
if let svgImage = SVGKImage(named: "world") {
SVGImageView(svgImage: svgImage)
.frame(width: UIScreen.main.bounds.width * 0.85)
.padding(.top)
}
Here is what this currently looks like: my app screenshot
I haven't tried anything yet, i've been looking at different repo's on github but haven't had any luck so I decided to ask it here.
I once did something similar to this before, but only for China. I used GeoJSON data instead of SVG, because I couldn't find a nice SVG for China's map. Another advantage of using GeoJSON is that you can easily change the map projection, and that the file size can be a lot smaller.
Here's the drawing code I used, adapted for SwiftUI. I used the CodableGeoJSON package just for adding Codable
GeoJSON types. You can totally write those types yourself by examining the structure of the JSON, so you don't have to use the package if you don't want.
struct CountryProperties: Codable {
let name: String
}
typealias MapFeatureCollection = GeoJSONFeatureCollection<MultiPolygonGeometry, CountryProperties>
struct GeoJSONMapDrawer {
let featureCollection: MapFeatureCollection?
let colorDict: [GeoJSONFeatureIdentifier: Color]
func drawMap(borderColor: Color, borderWidth: CGFloat, size: CGSize, context: GraphicsContext) {
func transformProjectedPoint(_ point: CGPoint) -> CGPoint {
point
.applying(CGAffineTransform(scaleX: size.width, y: size.height))
}
guard let featureCollection = self.featureCollection else { return }
let features = featureCollection.features
for feature in features {
guard let multipolygon = feature.geometry?.coordinates else { continue }
var multiPolygonPath = Path()
let fillColor = colorDict[feature.id ?? ""] ?? .clear
for polygon in multipolygon {
let firstLinearRing = polygon.first!
for (index, position) in firstLinearRing.enumerated() {
if index == 0 {
multiPolygonPath.move(to:
transformProjectedPoint(project(long: position.longitude, lat: position.latitude))
)
} else {
multiPolygonPath.addLine(to:
transformProjectedPoint(project(long: position.longitude, lat: position.latitude))
)
}
}
multiPolygonPath.closeSubpath()
}
context.fill(multiPolygonPath, with: .color(fillColor))
context.stroke(multiPolygonPath, with: .color(borderColor), lineWidth: borderWidth)
}
print("Done!")
}
// this converts a longitude and latitude into a coordinate in a unit square
func project(long: Double, lat: Double) -> CGPoint {
let lowestLongitude: Double = -180
let longitudeRange: Double = 360
// the top and bottom of the map needs to be truncated,
// because of how the mercator projection works
// here I truncated the top 10 degrees and the bottom 24 degrees, as is standard
let lowestLatitudeMercator: Double = mercator(-66)
let latitudeRangeMercator: Double = mercator(80) - mercator(-66)
let projectedLong = CGFloat((long - lowestLongitude) / longitudeRange)
let projectedLat = CGFloat(1 - ((mercator(lat) - lowestLatitudeMercator) / latitudeRangeMercator))
return CGPoint(x: projectedLong, y: projectedLat)
}
func mercator(_ lat: Double) -> Double {
asinh(tan(lat * .pi / 180))
}
}
As you can see in the code, I drew the map using the mercator projection, simply because the longitude doesn't need to change at all.
Then you can draw this with a SwiftUI Canvas
:
struct ContentView: View {
var body: some View {
Canvas(rendersAsynchronously: true) { context, size in
// as an example, I coloured Russia green
let drawer = GeoJSONMapDrawer(featureCollection: loadGeojson(), colorDict: [
// the keys in this dictionary corresponds to the "id" property in each feature
"RU": .green
])
drawer.drawMap(borderColor: .black, borderWidth: 1, size: size, context: context)
}
// from a quick google, 1.65 is apparently best for a mercator map
.aspectRatio(1.65, contentMode: .fit)
}
func loadGeojson() -> MapFeatureCollection {
let data = try! Data(contentsOf: Bundle.main.url(forResource: "world", withExtension: "json")!)
return try! JSONDecoder().decode(MapFeatureCollection.self, from: data)
}
}
The GeoJSON file I used is: https://gist.githubusercontent.com/markmarkoh/2969317/raw/15c2e3dee7769bb77b62d2a202548e7cce039bce/gistfile1.js Note that there is the extra var countries_data =
at the start of the file, which you should remove.
Output: