Code-Along Project With MapKit for SwiftUI - Part 6

Plotting a route from a GPX file and adding annotations

ยท

10 min read

(Photo by Michael Briguet on Unsplash)

For iOS17 and Xcode 15 beta, or now even RC :)

Exploring SwiftUI with MapKit can be quite fun.

In iOS 17 and macOS 14, MapKit has reached a new level with a few new additions for SwiftUI. Keep in mind that these views and modifiers are still in beta and are subject to change without notice.

This brief series of posts draws inspiration from the WWDC23 talk: WWDC23 - Meet MapKit for SwiftUI

This is a code-along project, the main branch has the starter project in Xcode, which is mostly empty. It should only display a map of your region.
You can check the 'completedProject' branch of the project on GitHub for the finished version: The GitHub repo with the project

Previously:
Part 1 - Display default search results
Part 2 - Controlling the region being displayed
Part 3 - Rediscovering the map camera!
Part 4 - Animations and Flyovers
Part 5 - En route with our map

In this episode, we'll dive into working with GPX files in MapKit for SwiftUI. It's worth noting that this tutorial is geared towards those with some prior familiarity with the delegate pattern.

Part 6 - Using a GPX file to draw a route

What is a GPX file?

A GPX (GPS Exchange Format) file is a standardized file format used to store GPS data, including waypoints, routes, and tracks. It was developed by TopoGrafix and is widely used for sharing GPS-related information across various platforms and devices.

GPX files are essentially XML files, meaning they follow the XML (Extensible Markup Language) syntax, which is a markup language designed for encoding data in a format that is both human-readable and machine-readable. This makes it easy to exchange GPS data between different applications and systems.

In iOS development, parsing GPX files involves extracting and interpreting the information contained within the XML structure. This is typically done using built-in frameworks like XMLParser, which allows iOS applications to navigate and extract data from XML documents. Developers can utilize this framework to access and use GPS data stored in GPX files within their iOS applications, enabling functionalities such as mapping, navigation, and location-based services.

When working with MapKit, displaying routes involves plotting a series of coordinates on a map to illustrate a path from one location to another. While MapKit provides tools for this, it's important to note that you cannot directly instantiate an MKRoute object to display a route on a map.

Instead, you'll need to use a MapPolyline to represent the route. A MKPolyline is a series of connected line segments that can be drawn on a map. To create a route using a MKPolyline, you'll first obtain the set of coordinates that define the route, typically from a source like a GPX file or a web service that provides directions.

Next, you'll use these coordinates to create an array of CLLocationCoordinate2D objects, which represent the points along the route. You'll then create a MKPolyline using these coordinates and add them to your map.

As per Apple documentation

An MKRoute object defines the geometry for the route โ€” that is, it contains line segments associated with specific map coordinates. A route object may also include other information, such as the name of the route, its distance, and the expected travel time.
You don't create instances of this class directly.

The last sentence made me somewhat sad ๐Ÿ˜ข. I am joking of course but Apple reserved MKRoute for use with the directions requests and it is fine. We will use MapPolyline to draw a route:

MapPolyline Apple Documentation

We will instantiate it from an array of CLLocationCoordinate2D.

Parsing the GPX file with XMLParser

Given that a GPX file encapsulates data in XML format, we can utilize XMLParser to extract the information necessary for our route. As a passionate cyclist, GPX files have been indispensable in my travels. For this exercise, I've employed a file sourced from bikepacking.com. You can see the route here.

XMLParser is a lightweight event based parser that which is read-only.This parser is known as a SAX parser which which stands for Simple API for XML. It is fast and does not keep the XML data in memory, instead it fires events while parsing an XML document.

it does not perform any validation and it is a read-only parser. This means we can't modify the XML nor can we write it to disk. We need to make sure to have a valid XML file.

We will be notified of the events as delegate of the parser using the XMLParserDelegate protocol. Therefore we will need three things:

  1. Create the data structure, the GPXData class, that we want to populate from our GPX file. This class will contain our route and waypoints to display.

  2. We need to create a parser class which will conform to the XMLParserDelegate protocol and will be notified of the events firing from the XMLParser. This will conform to NSObject because required by the XMLParserDelegate.

  3. A State variable to contain the route data as an array of points which will be displayed in the MapPolyline in our SwiftUI map view.

Creating our data model for the route

In a real-world scenario, it would be best practice to put the model into a separate file and implement further decoupling. However, for the purpose of this blog post, we'll focus on making the model function with our data to demonstrate how the parser operates.

The GPX file I downloaded from the bike packing website looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:gpxdata="http://www.cluetrust.com/XML/GPXDATA/1/0" xmlns="http://www.topografix.com/GPX/1/0" xsi:schemaLocation="http://www.topografix.com/GPX/1/0 http://www.topografix.com/GPX/1/0/gpx.xsd http://www.cluetrust.com/XML/GPXDATA/1/0 http://www.cluetrust.com/Schemas/gpxdata10.xsd" version="1.0" creator="http://ridewithgps.com/">
  <author>RideWithGPS LLC</author>
  <url>https://ridewithgps.com/routes/30250612</url>
  <time>2019-06-12T02:13:49Z</time>
  <wpt lon="6.335894018411636" lat="43.80451999604702">
    <name>ALTERNATIVE</name>
    <sym>Dot</sym>
    <type>Dot</type>
  </wpt>
...
      <trkpt lat="43.8469" lon="6.22153">
        <ele>633.1</ele>
      </trkpt>
    </trkseg>
  </trk>
</gpx>

I have a header giving me the author and other metadata, then a series of waypoints with the <wpt>...</wpt> tag. Then the route as a series of tracepoints with coordinate and elevation data.

<trkpt lat="43.8469" lon="6.22153">
        <ele>633.1</ele>
 </trkpt>

I am interested in the coordinates and will skip the elevation in this implementation today. We have already some unwieldy amount of code ahead of us!

This is the model:

// our data model
struct GPXData: CustomDebugStringConvertible {
    // properties of the xml file
    var author: String = ""
    var url: String = ""
    var time: String = ""

    // properties computed by our parser
    var route = [CLLocationCoordinate2D]()
    var waypoints = [Waypoint]()

    var debugDescription: String {
        return "Author: \(author), url: \(url)"
    }
}
// to display a waypoint I need some more info so I made a separate struct
struct Waypoint: Identifiable {
    let id = UUID()
    var name: String = ""
    var coord: CLLocationCoordinate2D
    // this is a string defining the sf image to use
    var image: String = ""
    var background: Color = .brown
}

Creating the parser

And this is our parser, conforming to NSObject and XMLParserDelegate. We start adding some properties we will need:

class GPXParser: NSObject, XMLParserDelegate  {
    // will be assigned in the init together with the data from the gpx file
    var xmlParser: XMLParser?
    // temp variable to store the chars fired by the parser
    var xmlText: String = ""
    // instantiating an empty data model
    var gpxData: GPXData = GPXData()
    // need this to create a temp waypoint
    var currentWaypoint: Waypoint?

// continues
}

Bear with me, it will be a lot of code but I will try to explain as we go along.

As delegates we get notified every time an opening tag is found and also when a closing tag is closed. The text between them is saved to a temp variable that we also defined as xmlText .

We will use an instance of our XMLParser to "connect" and setting our class to be the delegate, and then we will get the updates from the XMLParser. This will happen in the init when we instantiate the XMLParser with our GPX file. So this will be our initializer, add this to the class:

    // We init with a gpx file in our bundle in this case but it could be on the web as well
    init(with gpxFilename: String) {
        // get the assets from the Bundle
        if let fileName = Bundle.main.url(forResource: gpxFilename, 
              withExtension: "gpx"),
           let data = try? Data(contentsOf: fileName) {
            xmlParser = XMLParser(data: data)
        }
    }

We then will use three delegate functions provided by the XMLParserDelegate:


    // delegate function didStartElement
    // it will notify me when the element tag is found as a start. I will use it for the latitude and longitude params
    func parser(_ parser: XMLParser,
                didStartElement elementName: String,
                namespaceURI: String?,
                qualifiedName qName: String?,
                attributes attributeDict: [String : String]) {
        // reset temp text to be empty
        xmlText = ""
        // in this method we will only look at two elements and get their coordinates
        switch elementName {
        case "trkpt":
            // from the XMLParser we get strings
            guard let latString = attributeDict["lat"],
                  let lonString = attributeDict["lon"] else { return }
            guard let lat = Double(latString), let lon = Double(lonString) else { return }
            gpxData.route.append(CLLocationCoordinate2D(latitude: lat, longitude: lon))

        // the waypoints will start with wpt. Here we get the coord
        case "wpt":
            // here we are always at the begin of a new waypoint data so reset
              currentWaypoint = nil
            // again we get the strings
            guard let latString = attributeDict["lat"],
                  let lonString = attributeDict["lon"] else { return }
            // we convert to double
            guard let lat = Double(latString), let lon = Double(lonString) else { return }
            // I will append it when I get the name in the other parser delegate
            currentWaypoint = Waypoint(coord: CLLocationCoordinate2D(latitude: lat, longitude: lon))

        default:
            return
        }
    }

    // this is another parser method we get from the delegate. This one fires when we get a closing tag for an element. Ideal to get the values from the header. When we get at the end of the tag our temp xmltext var will be populated with the string inside the tags
    func parser(_ parser: XMLParser, 
        didEndElement elementName: String, 
        namespaceURI: String?, 
        qualifiedName qName: String?) {
        // these are from the XML header
        if elementName == "author" {
            gpxData.author = xmlText.trimmingCharacters(in: .whitespacesAndNewlines)
        }
        if elementName == "url" {
            gpxData.url = xmlText.trimmingCharacters(in: .whitespacesAndNewlines)
        }
        if elementName == "time" {
            gpxData.time = xmlText.trimmingCharacters(in: .whitespacesAndNewlines)
        }
        // Waypoint property. adding the name, choosing image and color and then appending the waypoint to the array
        if elementName == "name" {
            guard var currentWaypoint else { return }
            currentWaypoint.name = xmlText.trimmingCharacters(in: .whitespacesAndNewlines)
            // check for the image to display
            if currentWaypoint.name.contains("CAMP") ||
                currentWaypoint.name.contains("SHELTER")
            {
                currentWaypoint.image = "tent"
                currentWaypoint.background = .brown
            } else if currentWaypoint.name.contains("Supermarket") {
                currentWaypoint.image = "basket.fill"
                currentWaypoint.background = .cyan
            } else {
                currentWaypoint.image = "mappin.circle.fill"
                currentWaypoint.background = .gray
            }
            // adding the name, image and color concludes the creation of 
            //my waypoint which has been just appended to the gpxData.waypoints
            gpxData.waypoints.append(currentWaypoint)
            }
    }
    // this delegate method populates our temp file xmlText
    func parser(_ parser: XMLParser, foundCharacters string: String) {
        xmlText += string
    }

The last function in our parser is the one actually getting the data out:

    //This is not a delegate function. We call it from outside to get our gpxData parsed
    func parse() -> GPXData {
        xmlParser?.delegate = self
        xmlParser?.parse()
        return gpxData
    }

Displaying everything on the map view

In our map view we will use these state variables:

@State private var route: [CLLocationCoordinate2D]?
@State private var waypoints: [Waypoint]?

and in the body we will show a route and the waypoints when they become available:

Map {

        if let route {
            MapPolyline(coordinates: route, contourStyle: .straight)
                .stroke(.blue, lineWidth: 5)
        }

        if let waypoints {
            ForEach(waypoints) { waypoint in
                Annotation(waypoint.name,
                           coordinate: waypoint.coord,
                           anchor: .bottom
                ){
                    Image(systemName: waypoint.image)
                        .padding(4)
                        .foregroundStyle(.white)
                        .background(waypoint.background)
                        .cornerRadius(2)
                }
                //.annotationTitles(.hidden)
            }
        }
    }
    .onAppear {
        getRouteGPX(file: "TransVerdon")
    }
}

In the onAppear() modifier we call the getRouteGPX function to get the data flowing into our state variables and populate the map:

    // this function will convert the GPX file to an array of CLLocationCoordinate2D
    func getRouteGPX(file: String) {
        // reset the previous if any
        route = nil
        // instantiate the parser
        let gpxParser = GPXParser(with: file)
        let gpxData = gpxParser.parse()
        route = gpxData.route
        waypoints = gpxData.waypoints
        print(gpxData.debugDescription)
        }

and this is our final result:

There are another ways to display routes. One of them is with a GeoJSON file. This is what we will see in a future episode. Stay tuned!

The GitHub repo with the project

WWDC23 - Meet MapKit for SwiftUI

WWDC22 - What's new in MapKit

MapKit docs MapKit for SwiftUI docs