jagreenwood/healthkit-workout-splits
A Swift Package for calculating distance-based splits from completed HealthKit workouts using distance sample aggregation.
Overview
HealthKitWorkoutSplits analyzes completed workouts and calculates approximate distance splits using HKQuantitySample distance data. This approach works for both indoor and outdoor workouts, making it compatible with treadmill runs, outdoor runs, cycling, swimming, and other distance-based activities.
Key Features
- Calculate splits for any distance unit (miles, kilometers, custom distances)
- Optional pause time exclusion from split calculations
- Support for multiple workout types (running, cycling, swimming, hiking)
- Modern Swift async/await API
- Comprehensive error handling
- Works with both indoor and outdoor workouts
How It Works
Instead of using GPS/location data, this package aggregates distance samples recorded by HealthKit. This means:
✅ Works with indoor workouts (treadmill, indoor cycling) ✅ Compatible with any device that records distance ✅ Simpler implementation and fewer privacy concerns
⚠️ Accuracy depends on sample frequency ⚠️ Provides approximate splits, not GPS-accurate timing
Requirements
- iOS 16.0+ / watchOS 9.0+ / macOS 13.0+
- Swift 5.9+
- Xcode 15.0+
Installation
Swift Package Manager
Add the package to your Package.swift file:
dependencies: [
.package(url: "https://github.com/yourusername/healthkit-workout-splits.git", from: "1.0.0")
]Or in Xcode:
- File → Add Package Dependencies
- Enter the repository URL
- Select version and add to your target
Quick Start
1. Request HealthKit Authorization
Add HealthKit entitlement and privacy description to your Info.plist:
<key>NSHealthShareUsageDescription</key>
<string>We need access to your workout data to calculate splits</string>Request authorization in your app:
import HealthKit
import HealthKitWorkoutSplits
let healthStore = HKHealthStore()
// Request authorization
try await SplitCalculator.requestAuthorization(from: healthStore)2. Calculate Splits for a Workout
import HealthKitWorkoutSplits
let calculator = SplitCalculator()
// Configure split distance (1 mile splits, excluding paused time)
let config = SplitConfiguration.miles(1.0, excludePausedTime: true)
// Calculate splits
do {
let splits = try await calculator.calculateSplits(
for: workout,
configuration: config,
healthStore: healthStore
)
// Display splits
for split in splits {
let minutes = Int(split.duration / 60)
let seconds = Int(split.duration.truncatingRemainder(dividingBy: 60))
let paceStr = split.pace.formattedPace(per: .miles)
print("Split \(split.splitNumber): \(minutes):\(String(format: "%02d", seconds)) (\(paceStr))")
}
} catch {
print("Error calculating splits: \(error.localizedDescription)")
}3. Handle Different Distance Units
// Kilometer splits
let kmConfig = SplitConfiguration.kilometers(1.0)
// Custom distance (e.g., 400 meters)
let customConfig = SplitConfiguration(
splitDistance: Measurement(value: 400, unit: .meters),
excludePausedTime: false
)Usage Examples
Displaying Split Results
let splits = try await calculator.calculateSplits(
for: workout,
configuration: .miles(1.0, excludePausedTime: true),
healthStore: healthStore
)
for split in splits {
print(split.description)
// Split 1: 1609.34m in 480.0s
// Access detailed properties
print("Distance: \(split.distance.formattedDistance())")
print("Duration: \(split.duration)s")
print("Pace: \(split.pace.formattedPace(per: .miles))")
print("Is Partial: \(split.isPartial)")
}Handling Errors
do {
let splits = try await calculator.calculateSplits(
for: workout,
configuration: config,
healthStore: healthStore
)
// Process splits...
} catch SplitCalculatorError.healthKitNotAvailable {
// HealthKit not supported on device
print("HealthKit is not available")
} catch SplitCalculatorError.noDistanceData {
// Workout has no distance samples
print("No distance data available for this workout")
} catch SplitCalculatorError.workoutTooShort {
// Workout has zero distance
print("Workout distance is too short")
} catch {
print("Unexpected error: \(error)")
}Working with Different Workout Types
The package automatically selects the correct distance type:
// Running/Walking/Hiking → distanceWalkingRunning
let runSplits = try await calculator.calculateSplits(
for: runningWorkout,
configuration: .miles(1.0),
healthStore: healthStore
)
// Cycling → distanceCycling
let cyclingSplits = try await calculator.calculateSplits(
for: cyclingWorkout,
configuration: .kilometers(5.0),
healthStore: healthStore
)
// Swimming → distanceSwimming
let swimSplits = try await calculator.calculateSplits(
for: swimmingWorkout,
configuration: .meters(100),
healthStore: healthStore
)Excluding Paused Time
// Include all time (default)
let config1 = SplitConfiguration.miles(1.0, excludePausedTime: false)
// Exclude paused time from split durations
let config2 = SplitConfiguration.miles(1.0, excludePausedTime: true)
let splits = try await calculator.calculateSplits(
for: workout,
configuration: config2,
healthStore: healthStore
)
// Split durations now reflect only active timeUsing Convenience Extensions
// HKWorkout extensions
print(workout.activityName) // "Running"
print(workout.hasPauses) // true/false
print(workout.totalPausedTime) // 120.0 (seconds)
print(workout.activeDuration) // Active time only
print(workout.formattedSummary) // "Running: 5.25 mi in 42:30"
// Measurement extensions
let pace = split.pace
print(pace.minutesPerMile) // 8.0
print(pace.minutesPerKilometer) // 5.0
print(pace.formattedPace(per: .miles)) // "8:00 /mi"API Reference
SplitCalculator
Main calculator class for determining splits.
public class SplitCalculator {
public init()
public static func requestAuthorization(from: HKHealthStore) async throws
public func calculateSplits(
for workout: HKWorkout,
configuration: SplitConfiguration,
healthStore: HKHealthStore
) async throws -> [WorkoutSplit]
}SplitConfiguration
Configuration for split calculations.
public struct SplitConfiguration {
public let splitDistance: Measurement<UnitLength>
public let excludePausedTime: Bool
public static func miles(_ distance: Double, excludePausedTime: Bool = false) -> SplitConfiguration
public static func kilometers(_ distance: Double, excludePausedTime: Bool = false) -> SplitConfiguration
}WorkoutSplit
Represents a single calculated split.
public struct WorkoutSplit {
public let splitNumber: Int // 1-indexed
public let distance: Measurement<UnitLength>
public let duration: TimeInterval // seconds
public let pace: Measurement<UnitSpeed>
public let timestamp: Date // approximate end time
public let isPartial: Bool // true if < target distance
}SplitCalculatorError
Errors that can occur during split calculation.
public enum SplitCalculatorError: Error {
case healthKitNotAvailable
case notAuthorized
case noDistanceData
case insufficientDistanceData
case invalidConfiguration(String)
case workoutTooShort
}Limitations and Considerations
Approximation, Not GPS Accuracy
This package uses distance sample aggregation, not GPS routes. Split times are approximate and depend on:
- Sample frequency: More frequent samples = more accurate splits
- Device capabilities: Different devices record samples at different rates
- Workout type: Indoor workouts may have different sample patterns than outdoor
Cannot Detect Permission Denial
HealthKit doesn't reveal whether the user denied read permissions. If permission is denied, queries return empty results, which appears as "no distance data." Always request authorization and handle the noDistanceData error gracefully.
Post-Workout Only
This package is designed for analyzing completed workouts. It's not suitable for real-time split calculations during an active workout.
Indoor vs Outdoor
The package works for both indoor and outdoor workouts as long as distance samples exist. However:
- Indoor treadmill workouts depend on the treadmill reporting accurate distance to the watch/phone
- Outdoor workouts generally have more frequent and accurate distance samples
Troubleshooting
"No distance data" error
Possible causes:
- The workout type doesn't record distance (e.g., strength training)
- Distance wasn't tracked during the workout
- HealthKit permissions not granted
- The device doesn't support distance tracking for this workout type
Solutions:
- Verify the workout has a
totalDistancevalue - Check that authorization was requested and granted
- Try a different workout with known distance data
Splits seem inaccurate
Possible causes:
- Low sample frequency during workout
- Sparse distance data
- Indoor workout with estimated distance
Solutions:
- Use workouts with GPS enabled (outdoor workouts)
- Accept that splits are approximations
- Compare with other split calculators to understand typical variance
Package won't compile
Possible causes:
- Missing HealthKit framework
- Incorrect platform version
Solutions:
- Ensure your deployment target is iOS 16+, watchOS 9+, or macOS 13+
- Add HealthKit capability to your app target
- Verify Swift tools version is 5.9+
Contributing
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Package Metadata
Repository: jagreenwood/healthkit-workout-splits
Default branch: main
README: README.md