Search code examples
iosswiftxcodesvgswiftui

How can i programmatically change the color of certain elements in an SVG file using Swift


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.


Solution

  • 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:

    enter image description here