GPX 1.1 (GPS Exchange Format) read and write support for Swift, built on top of gis-tools. Parses waypoints, routes, and tracks into typed FeatureCollection objects — and writes them back as valid GPX XML.
- Reads and writes GPX 1.1 files, with read-only support for GPX 1.0
- Waypoints (
<wpt>) →Feature<Point>, Routes (<rte>) →Feature<LineString>, Tracks (<trk>) →Feature<MultiLineString> - Typed convenience API on
FeatureandFeatureCollection— no manual dictionary casting - Full
<metadata>block (name, author, copyright, bounds, keywords, time, links) - Garmin TrackPointExtension v2 (
gpxtpx): hr, cad, power, speed, course, bearing, temperature, depth - Garmin GpxExtensions v3 (
gpxx): Address, Categories, PhoneNumber, DisplayColor, IsAutoNamed, rpt - Structured extension values:
Address→GPXAddress,Categories→[String],PhoneNumber→[GPXPhoneNumber] - Automatic type inference for extension values (booleans, integers, floating-point, strings)
- Per-point sensor arrays: heart rate, cadence, power, speed, temperature — stored as parallel arrays
gpxPointFeatures(): expand track points into individualPointfeatures with per-point sensor data- Time-window and distance-window slicing of point features
- GPX 1.0-specific fields:
course/speedon track points, flaturl/urlnamelinks - Round-trip fidelity: write → read preserves element types, coordinates, properties, and extensions
Swift 6.1 or higher. Compiles on iOS (≥ iOS 15), macOS (≥ macOS 15), tvOS (≥ tvOS 15), watchOS (≥ watchOS 7), Linux, Android and Wasm. No external dependencies beyond the base gis-tools package.
dependencies: [
.package(url: "https://github.com/Outdooractive/gis-tools-gpx", from: "1.0.0"),
.package(url: "https://github.com/Outdooractive/gis-tools", from: "2.0.0"),
],
targets: [
.target(name: "MyTarget", dependencies: [
.product(name: "GISToolsGPX", package: "gis-tools-gpx"),
.product(name: "GISTools", package: "gis-tools"),
]),
]import GISTools
import GISToolsGPX
let url = URL(fileURLWithPath: "/path/to/file.gpx")
let fc = try GPXCoder.read(from: url)
// Or via the convenience init:
guard let fc = FeatureCollection(gpx: url) else { return }try fc.writeGPX(to: outputURL)
// Or via the coder directly:
try GPXCoder.write(fc, to: outputURL)Each Feature carries a gpxType property identifying its origin:
for feature in fc.features {
switch feature.gpxType {
case .waypoint: print("Waypoint: \(feature.gpxName ?? "unnamed")")
case .route: print("Route: \(feature.gpxName ?? "unnamed")")
case .track: print("Track: \(feature.gpxName ?? "unnamed")")
case nil: print("Unknown GPX type")
}
}let waypoint = fc.features.first!
print(waypoint.gpxName) // "Reichstag (Berlin)"
print(waypoint.gpxSymbol) // "City"
print(waypoint.gpxFix) // .threeDimensional
print(waypoint.gpxSatellites) // 12
print(waypoint.gpxHDOP) // 1.5
print(waypoint.gpxTime) // Optional<Date>
// Coordinate access
let coord = (waypoint.geometry as! Point).coordinate
print(coord.latitude) // 52.518611
print(coord.longitude) // 13.376111
print(coord.altitude) // 35.0let track = fc.features[1]
let multiLine = track.geometry as! MultiLineString
for (i, segment) in multiLine.lineStrings.enumerated() {
print("Segment \(i): \(segment.coordinates.count) points")
}
let route = fc.features[2]
let lineString = route.geometry as! LineString
print("Route has \(lineString.coordinates.count) waypoints")Track-point-level fitness data via typed properties:
var pointFeature = Feature(Point(Coordinate3D(latitude: 52.5, longitude: 13.3)))
pointFeature.gpxHeartRate = 145
pointFeature.gpxCadence = 90
pointFeature.gpxPower = 220 // watts
pointFeature.gpxSpeed = 12.5 // m/s
pointFeature.gpxCourse = 45.0 // degrees true
pointFeature.gpxAirTemperature = 18.5
pointFeature.gpxDepth = 2.0 // meters
// Reading back:
let hr = feature.gpxHeartRate // Int? (145)
let cad = feature.gpxCadence // Int? (90)
let speed = feature.gpxSpeed // Double? (12.5)Track-level fitness data (gpxtpx extensions on <trkpt>) is accumulated into parallel arrays on the track Feature:
let track = fc.features.first { $0.gpxType == .track }!
let hr = track.gpxHeartRates // [Int?]? — [120, 145, 160, ...]
let cad = track.gpxCadences // [Int?]? — [80, 90, 95, ...]
let pw = track.gpxPowers // [Int?]? — [150, 220, ...] (nil = missing)
let spd = track.gpxSpeeds // [Double?]? — [12.5, ...]
let tmp = track.gpxAirTemperatures // [Double?]? — [22.5, ...]
let elev = track.gpxElevations // [Double?]? — [35.0, 38.0, ...]
// Arrays align with coordinates — nil where no data was recorded
for i in 0..<(track.gpxHeartRates?.count ?? 0) {
if let hr = hr?[i], let cad = cad?[i] {
print("Point \(i): HR=\(hr), cad=\(cad)")
}
}Expand the MultiLineString track into individual Point features, each carrying its own sensor data:
let pts = track.gpxPointFeatures()
pts.features.count // 3 (one per track point)
pts.features[0].gpxHeartRate // 120 (via convenience accessor)
pts.features[2].gpxSpeed // 12.5 (reads from extensions["gpxtpx"])
// Time-window slicing
let morning = track.gpxPointFeatures(from: morningDate, to: noonDate)
// Distance-window slicing (meters along track)
let lastKm = track.gpxPointFeatures(from: totalM - 1000, to: totalM)
// Fractional-window slicing (0.0–1.0)
let middle = track.gpxPointFeatures(fraction: 0.33, to: 0.66)Convert a FeatureCollection of Point features back into a Feature<MultiLineString> track with per-point sensor arrays rebuilt:
let pts = track.gpxPointFeatures()
// Via FeatureCollection:
let rebuilt = pts.gpxTrackFromPointFeatures()
rebuilt?.gpxHeartRates?[0] // 120
rebuilt?.gpxSpeeds?[2] // 12.5
// Via Feature convenience init (also reads extensions["gpxtpx"]):
let track = Feature(gpxTrackFrom: pts)
track?.gpxCadences?[1] // 90
track?.gpxHeartRate // nil (track Feature has arrays, not single values)Non-Point features are silently skipped. Returns nil if no Point features exist. The gpxtpx extension data (hr, cad, power, speed, atemp) is extracted from each point and re-accumulated into parallel arrays, so round-trips preserve sensor data. The track has a single segment (no lap splitting).
Address, categories, and phone numbers as structured types:
let address = waypoint.gpxAddress
print(address?.streetAddresses) // ["Teststrasse 123", "Floor 4"]
print(address?.city) // "Berlin"
print(address?.country) // "Germany"
print(address?.postalCode) // "10115"
let categories = waypoint.gpxCategories
print(categories) // ["Office", "Technology", "Outdoor"]
for phone in waypoint.gpxPhoneNumbers {
print("\(phone.category ?? "other"): \(phone.value)")
// Work: +49-30-555-1234
// Mobile: +49-170-555-5678
}
let isAutoNamed = route.gpxIsAutoNamed // Bool?
let color = route.gpxDisplayColor // String? ("Red", "Blue", etc.)import GISTools
import GISToolsGPX
var fc = FeatureCollection()
// Add metadata
fc.gpxMetadataName = "My GPX File"
fc.gpxMetadataKeywords = "hiking, alps"
fc.gpxMetadataAuthor = GPXPerson(
name: "Jane Hiker",
email: "jane@example.com")
fc.gpxMetadataBounds = BoundingBox(
southWest: Coordinate3D(latitude: 47.0, longitude: 10.0),
northEast: Coordinate3D(latitude: 48.0, longitude: 11.0))
// Add a waypoint
var wpt = Feature(Point(Coordinate3D(latitude: 47.56, longitude: 10.22)))
wpt.gpxType = .waypoint
wpt.gpxName = "Alpine Lodge"
wpt.gpxSymbol = "Lodging"
wpt.gpxElevation = 1420.0
wpt.gpxAddress = GPXAddress(
streetAddresses: ["Bergstrasse 1"],
city: "Oberstdorf",
country: "Germany",
postalCode: "87561")
fc.features.append(wpt)
// Add a route
var rte = Feature(LineString(unchecked: [
Coordinate3D(latitude: 47.56, longitude: 10.22),
Coordinate3D(latitude: 47.57, longitude: 10.25),
Coordinate3D(latitude: 47.59, longitude: 10.30),
]))
rte.gpxType = .route
rte.gpxName = "Summit Trail"
rte.gpxTypeName = "Hiking"
fc.features.append(rte)
// Write to file
try fc.writeGPX(to: outputURL)var wpt = Feature(Point(Coordinate3D(latitude: 47.56, longitude: 10.22)))
wpt.gpxType = .waypoint
wpt.gpxName = "Checkpoint"
// Fitness data
wpt.gpxHeartRate = 120
wpt.gpxPower = 180
// Structured address
wpt.gpxAddress = GPXAddress(
streetAddresses: ["Alpenstraße 1"],
city: "Innsbruck",
country: "Austria")
// Categories and phone numbers
wpt.gpxCategories = ["Hiking", "Rest Stop"]
wpt.gpxPhoneNumbers = [
GPXPhoneNumber(value: "+43-512-555", category: "Info"),
]
// Route display properties
wpt.gpxDisplayColor = "Red"
wpt.gpxIsAutoNamed = false
// Write — all extension data round-trips
try FeatureCollection([wpt]).writeGPX(to: outputURL)GPX 1.0 files are parsed with backward-compatible handling:
let fc = try GPXCoder.read(from: gpx10URL)
// GPX 1.0 flat links (url/urlname) converted to GPXLink
let waypoint = fc.features.first!
for link in waypoint.gpxLinks {
print("\(link.href): \(link.text ?? "")")
// https://www.bundestag.de: Bundestag website
}
// GPX 1.0 course/speed on track points (not extensions)
// These are stored as gpxCourse10 / gpxSpeed10 on the waypoints
// within track segments, not on the track Feature itself.OpenStreetMap GPS traces are standard GPX 1.1 files — they read and write like any other GPX:
let trace = try GPXCoder.read(from: osmURL)
let track = trace.features.first { $0.gpxType == .track }!
let multiline = track.geometry as! MultiLineString
print("\(multiline.lineStrings[0].coordinates.count) trace points")
// OSM traces often use cmt/desc for tagging
print(track.gpxDescription) // e.g. "Walking trace through Berlin Mitte"If you need the raw extension dictionary (for namespaces not covered by the typed API):
let ext = feature.properties["extensions"] as? [String: Sendable]
let gpxtpx = ext?["gpxtpx"] as? [String: Sendable]
let gpxx = ext?["gpxx"] as? [String: Sendable]
// Direct key access
let hr = gpxtpx?["hr"] as? Int
let city = (gpxx?["Address"] as? [String: Sendable])?["City"] as? StringOn Feature:
| Property | Type | GPX Source |
|---|---|---|
gpxType |
GPXElementType? |
.waypoint, .route, .track |
gpxName |
String? |
<name> |
gpxComment |
String? |
<cmt> |
gpxDescription |
String? |
<desc> |
gpxSource |
String? |
<src> |
gpxSymbol |
String? |
<sym> |
gpxTypeName |
String? |
<type> |
gpxFix |
GPSFixType? |
<fix> |
gpxSatellites |
Int? |
<sat> |
gpxHDOP |
Double? |
<hdop> |
gpxVDOP |
Double? |
<vdop> |
gpxPDOP |
Double? |
<pdop> |
gpxTime |
Date? |
<time> |
gpxLinks |
[GPXLink] |
<link> |
gpxCourse10 |
Double? |
<course> (GPX 1.0) |
gpxSpeed10 |
Double? |
<speed> (GPX 1.0) |
gpxHeartRate |
Int? |
gpxtpx hr |
gpxCadence |
Int? |
gpxtpx cad |
gpxPower |
Int? |
gpxtpx power |
gpxSpeed |
Double? |
gpxtpx speed |
gpxCourse |
Double? |
gpxtpx course |
gpxBearing |
Double? |
gpxtpx bearing |
gpxAirTemperature |
Double? |
gpxtpx atemp |
gpxWaterTemperature |
Double? |
gpxtpx wtemp |
gpxDepth |
Double? |
gpxtpx depth |
gpxHeartRates |
[Int?]? |
gpxtpx hr (per-point array) |
gpxCadences |
[Int?]? |
gpxtpx cad (per-point array) |
gpxPowers |
[Int?]? |
gpxtpx power (per-point array) |
gpxSpeeds |
[Double?]? |
gpxtpx speed (per-point array) |
gpxAirTemperatures |
[Double?]? |
gpxtpx atemp (per-point array) |
gpxElevations |
[Double?]? |
<ele> (per-point array) |
gpxTimes |
[Date?]? |
<time> (per-point array) |
gpxProximity |
Double? |
gpxx Proximity |
gpxDisplayMode |
String? |
gpxx DisplayMode |
gpxAddress |
GPXAddress? |
gpxx Address |
gpxCategories |
[String] |
gpxx Categories |
gpxPhoneNumbers |
[GPXPhoneNumber] |
gpxx PhoneNumber |
gpxIsAutoNamed |
Bool? |
gpxx IsAutoNamed |
gpxDisplayColor |
String? |
gpxx DisplayColor |
On FeatureCollection:
| Property | Type | GPX Source |
|---|---|---|
gpxWaypoints |
[Feature] |
waypoints (filtered by gpxType) |
gpxRoutes |
[Feature] |
routes (filtered by gpxType) |
gpxTracks |
[Feature] |
tracks (filtered by gpxType) |
gpxMetadataName |
String? |
<metadata><name> |
gpxMetadataDescription |
String? |
<metadata><desc> |
gpxMetadataKeywords |
String? |
<metadata><keywords> |
gpxMetadataTime |
Date? |
<metadata><time> |
gpxMetadataAuthor |
GPXPerson? |
<metadata><author> |
gpxMetadataCopyright |
GPXCopyright? |
<metadata><copyright> |
gpxMetadataLinks |
[GPXLink] |
<metadata><link> |
gpxMetadataBounds |
BoundingBox? |
<metadata><bounds> |
Methods on FeatureCollection:
| Method | Returns | Description |
|---|---|---|
gpxTrackFromPointFeatures() |
Feature? |
Reconstructs a Feature<MultiLineString> from Point features produced by gpxPointFeatures(). gpxtpx sensor data is re-accumulated into per-point arrays. Returns nil if no Point features exist. |
Convenience initializer on Feature:
| Initializer | Description |
|---|---|
Feature(gpxTrackFrom:) |
Creates a track Feature from a FeatureCollection of Point features. Same as gpxTrackFromPointFeatures(). |
Public model types:
| Type | Fields |
|---|---|
GPXLink |
href: String, text: String?, type: String? |
GPXPerson |
name: String?, email: String?, link: GPXLink? |
GPXCopyright |
author: String, year: Int?, license: String? |
GPXAddress |
streetAddresses: [String], city: String?, state: String?, country: String?, postalCode: String? |
GPXPhoneNumber |
value: String, category: String? |
| Prefix | Namespace URI | Elements |
|---|---|---|
gpxtpx |
http://www.garmin.com/xmlschemas/TrackPointExtension/v2 |
hr, cad, power, speed, course, bearing, atemp, wtemp, depth |
gpxx |
http://www.garmin.com/xmlschemas/GpxExtensions/v3 |
Address, Categories, PhoneNumber, WaypointExtension, RouteExtension, TrackExtension, RoutePointExtension, TrackPointExtension, Proximity, Temperature, Depth, DisplayMode, DisplayColor, IsAutoNamed, Subclass, rpt |
- GPX 1.0 is read-only; written files always use GPX 1.1
- List-type extensions (Categories, PhoneNumber) use generic element names on write; exact child-element names may differ on round-trip
- Track-point-level and route-point-level extensions are lost during write because only track/route
Featureobjects are serialized (segment-level data is not individually written) Subclass(hexBinary) andrptautoroute points are parsed but not exposed via the typed API
Please create an issue or open a pull request with a fix or enhancement.
MIT
Thomas Rasch, Outdooractive
Built on top of gis-tools.