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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Advanced Estimated time: 2–4 weeks Updated: May 12, 2026

Prerequisites

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

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.