How to Build a Cycling Tracker App in SwiftUI
A cycling tracker records GPS routes in the background, calculates speed and elevation gain in real time, and keeps every ride stored locally on the rider's device. It's aimed at cyclists who want privacy-first workout logs without sending data to a third-party cloud service.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Solid Swift/SwiftUI knowledge; familiarity with async/await
- A physical iPhone for testing — background CoreLocation tracking does not work reliably in Simulator
- Understand the CLLocationManager "Always" vs "When In Use" authorization distinction before writing a line of GPS code
Architecture overview
The data layer is SwiftData: a CyclingRide model holds ride summary stats with a cascade-delete relationship to RoutePoint rows that store raw GPS fixes. An @Observable LocationManager wraps CLLocationManager, filters inaccurate fixes by horizontal accuracy, and streams coordinates during an active ride. Three main views cover the experience — a dashboard (@Query-driven ride list), an active-ride screen (live Map with MapPolyline), and a ride detail screen built with Swift Charts. No network calls; everything stays on-device.
CyclingTrackerApp/ ├── Models/ │ ├── CyclingRide.swift # SwiftData @Model, ride summary + stats │ └── RoutePoint.swift # SwiftData @Model, GPS coordinate row ├── Managers/ │ └── LocationManager.swift # @Observable CLLocationManager wrapper ├── Views/ │ ├── DashboardView.swift # @Query ride list, start-ride button │ ├── ActiveRideView.swift # Live Map + MapPolyline + stat tiles │ └── RideDetailView.swift # MapKit replay + Swift Charts pace graph └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define two SwiftData models — CyclingRide for ride summaries and RoutePoint for individual GPS fixes — linked by a cascade-delete relationship so removing a ride also clears its coordinates.
import SwiftData
import CoreLocation
@Model
final class CyclingRide {
var id: UUID = UUID()
var startDate: Date = Date.now
var endDate: Date?
var distanceMeters: Double = 0
var durationSeconds: Double = 0
var elevationGainMeters: Double = 0
var averageSpeedKph: Double = 0
var maxSpeedKph: Double = 0
var title: String = ""
@Relationship(deleteRule: .cascade)
var routePoints: [RoutePoint] = []
}
@Model
final class RoutePoint {
var latitude: Double = 0
var longitude: Double = 0
var altitude: Double = 0
var timestamp: Date = Date.now
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
}
2. Core UI — Dashboard
Use @Query to fetch rides in reverse-chronological order inside a NavigationStack; a primary toolbar button launches the full-screen active ride view.
struct DashboardView: View {
@Query(sort: \CyclingRide.startDate, order: .reverse)
private var rides: [CyclingRide]
@State private var showingActiveRide = false
var body: some View {
NavigationStack {
List {
if !rides.isEmpty {
Section("Recent Rides") {
ForEach(rides) { ride in
NavigationLink(value: ride) {
RideRowView(ride: ride)
}
}
}
}
}
.navigationTitle("Cycling")
.navigationDestination(for: CyclingRide.self) { ride in
RideDetailView(ride: ride)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Start Ride", systemImage: "bicycle") {
showingActiveRide = true
}
}
}
.fullScreenCover(isPresented: $showingActiveRide) {
ActiveRideView()
}
}
}
}
3. Route mapping and live stats
An @Observable LocationManager filters fixes by horizontal accuracy, accumulates distance, and streams coordinates so ActiveRideView can paint a live MapPolyline and update stat tiles every second.
@Observable
final class LocationManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
var routePoints: [CLLocation] = []
var isTracking = false
var currentSpeedKph: Double = 0
var distanceKm: Double = 0
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
manager.allowsBackgroundLocationUpdates = true
manager.pausesLocationUpdatesAutomatically = false
}
func startTracking() {
routePoints = []; distanceKm = 0; isTracking = true
manager.startUpdatingLocation()
}
func stopTracking() { isTracking = false; manager.stopUpdatingLocation() }
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard isTracking, let loc = locations.last,
loc.horizontalAccuracy < 20 else { return }
if let prev = routePoints.last {
distanceKm += loc.distance(from: prev) / 1000
}
currentSpeedKph = max(0, loc.speed) * 3.6
routePoints.append(loc)
}
}
4. Privacy Manifest setup
Apple rejects builds at upload time if PrivacyInfo.xcprivacy is missing or incomplete; configure both the manifest and the "Always" authorization upgrade flow before your first TestFlight build.
// LocationManager+Permissions.swift
// Upgrades authorization from "When In Use" to "Always"
// so background location tracking stays active with screen locked.
extension LocationManager {
func requestPermissions() {
switch manager.authorizationStatus {
case .notDetermined:
manager.requestWhenInUseAuthorization()
case .authorizedWhenInUse:
manager.requestAlwaysAuthorization()
case .authorizedAlways:
manager.allowsBackgroundLocationUpdates = true
default:
break // Surface a Settings deep-link in the UI
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if manager.authorizationStatus == .authorizedAlways {
manager.allowsBackgroundLocationUpdates = true
}
}
}
// PrivacyInfo.xcprivacy: File ▶ New ▶ App Privacy
// NSPrivacyAccessedAPICategoryLocation → reason CA92.1
// Info.plist: UIBackgroundModes → ["location"]
// Capabilities tab: Background Modes ▶ Location updates ✓
Common pitfalls
- Background Modes capability not enabled. Setting
allowsBackgroundLocationUpdates = truethrows an exception at runtime if the "Location updates" background mode isn't toggled on under Signing & Capabilities. Enable it early — it's easy to forget and hard to debug on device. - Skipping accuracy filtering. Raw CLLocation fixes with
horizontalAccuracy > 20 minflate cumulative distance by 5–15% on a typical ride. Always guard on accuracy before appending a point. - Elevation gain wildly off. GPS altitude has ±10 m error. Naively summing all altitude deltas produces inflated climb figures. Apply a moving average or pair with barometric data via
CMAltimeterfor anything meaningful. - App Store rejection for vague location string. Reviewers reject if
NSLocationAlwaysAndWhenInUseUsageDescriptiondoesn't justify background tracking plainly. Write something like: "Cycling Tracker records your GPS route in the background while you ride so no part of your route is missed." - Missing PrivacyInfo.xcprivacy blocks upload. Transporter rejects the build immediately — you won't reach review at all. Add the file when you add CoreLocation, not the night before submission.
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to load monthly and annual plans you define in App Store Connect. Gate premium features — unlimited ride history, CSV export, and route heatmaps — behind an entitlement check. Verify active entitlements on cold start via Transaction.currentEntitlements and cache the result with @AppStorage so the paywall doesn't flash every launch. Attach a .onInAppPurchaseCompletion modifier to your paywall view to handle both new purchases and restorations in one place, and always validate server-side before unlocking permanent data exports.
Shipping this faster with Soarias
Soarias scaffolds the full project structure from your description, pre-wires the SwiftData schema with the cascade-delete relationship, generates PrivacyInfo.xcprivacy with CoreLocation reason codes already filled in, and writes the Info.plist background-mode entry. Fastlane match is configured automatically for code signing, and the first TestFlight build is uploaded through the App Store Connect API — no manual Xcode Organizer clicks.
An advanced app like this normally burns two to three days on provisioning, entitlements, and ASC metadata before a line of feature code is written. Soarias compresses that overhead to under two hours, so your first GPS-tracked test ride on a real device happens on day one.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A free account lets you run the app on your personal device via Xcode, but background location entitlements are restricted and TestFlight distribution is unavailable. The $99/year Apple Developer Program membership is required before you can reach real testers or submit to the App Store.
How do I submit this to the App Store?
Archive the app in Xcode (Product ▸ Archive), then use Organizer to validate and upload to App Store Connect. Fill in the privacy nutrition labels (location data, not linked to user), provide screenshots for all required device sizes, and submit for review. First submissions for location-using apps typically receive a 1–3 day review turnaround.
How do I keep GPS tracking alive when the screen locks?
Enable "Location updates" under Signing & Capabilities ▸ Background Modes in Xcode, then set manager.allowsBackgroundLocationUpdates = true and manager.pausesLocationUpdatesAutomatically = false on your CLLocationManager. You must hold the "Always" authorization level — "When In Use" only tracks while the app is on screen, which stops recording the moment the rider locks their phone.
Last reviewed: 2026-05-12 by the Soarias team.