Nov 07, 2021

Accessing Workouts with HealthKit and Swift

Recently, I wanted to access the workouts tracked and saved on my Apple Watch with a Swift application, as I needed the coordinates of every workout that I recorded so far. Initially, I wasn't sure whether this type of data was accessible at all, but luckily, everything was already in place.

In this guide, I will walk through how I got access to the data I needed, including all setup steps. But before we can dive into the code, let’s go through what HealthKit does, how workouts are stored, and how we can access user workouts.

HealthKit: A unified collection of health and fitness data

HealthKit is the central place for health and fitness data on iPhone and Apple Watch. Storing all data in one place allows multiple apps to be part of a huge ecosystem of recording and saving workouts and reading and sharing this data. You could track a workout with the Apple Watch or your favorite app and share it with your friends through a completely different app, all powered by the unified HealthKit interface.

Apple is very serious about protecting your health data, so before we can access it, we'll have to enable HealthKit capabilities for our application and provide a reason for why our application needs access. Then, the user has to approve sharing their health data. You also have to provide a privacy policy when using HealthKit.

HealthKit stores health and fitness data using HKSample subclasses, which include start and end times. For different types of data, HealthKit uses quantities for numerical values, categories for options selected from a short list, correlations for combinations of multiple samples into one, and more.

In this guide, we're interested in HKWorkout and HKWorkoutRoute.

Enabling HealthKit for your SwiftUI application

To enable HealthKit capabilities, head into your project settings, then your iOS application target, then Signing & Capabilities. In here, click + Capability, then select HealthKit. We will not check any further options here, since we just need read/write access to workouts.

If you need access to clinical health records, there are more precautions you need to take in order to protect your users' privacy.

We're not quite done yet, though. When requesting access to health data, we must specify a reason for it through the Info.plist file. If your environment doesn't include one (you're probably running Xcode 13 with SwiftUI), just head over to the Info tab in the project settings, which is one tab apart from Signing & Capabilities.

In here, we need to add two new rows (add by right-clicking the list and selecting Add Row). We want to add

If you forget to add those entries in your Info.plist file, your application may just run into a fatal breakpoint or crash when trying to authorize HealthKit, with a very non-descriptive error, so make sure this is set up correctly.

Requesting access to workout data

With HealthKit enabled for our application, we can continue to request access to the current user's workouts. To make sure HealthKit is available on the current device, we can use

import HealthKit

HKHealthStore.isHealthDataAvailable()

To request authorization for HealthKit and query actual data, we need to create a HealthKit store. This is a long-lived object we should only create once per app. You can instantiate this anywhere you can create global state, as long as it stays around for the entire lifecycle and is available where we need it.

let store = HKHealthStore()

In the next step, we'll use requestAuthorization to ask the user to share the health data we need for our application. After a user has authorized our application, the sheet will not show up again.

The requestAuthorization method receives two important arguments: toShare and read. toShare includes all data types that our application will store in HealthKit. read represents all data types our application wants to read.

We'll use the new async/await syntax instead of callbacks.

func requestPermission () async -> Bool {
    let write: Set<HKSampleType> = [.workoutType()]
    let read: Set = [
        .workoutType(),
        HKSeriesType.activitySummaryType(),
        HKSeriesType.workoutRoute(),
        HKSeriesType.workoutType()
    ]

    let res: ()? = try? await store.requestAuthorization(toShare: write, read: read)
    guard res != nil else {
        return false
    }

    return true
}

Now that we have everything in place, we can request access when our application starts up.

import Foundation
import SwiftUI
import HealthKit

struct ContentView: View {
    @State var isReady = false

    var body: some View {
        VStack {
            if isReady {
							Text("Let's read some workouts")
            } else {
                Text("Please authorize the application")
            }
        }
        .task {
            if !HKHealthStore.isHealthDataAvailable() {
                return
            }

            guard await requestPermission() == true else {
                return
            }

            isReady = true
        }

    }
}

This simple view will check if HealthKit is available on the device, and if so, request access using the function we wrote before. When everything is set, it updates isReady to true, re-rendering the view content.

We're done with all preparation steps now, so let's get to reading workouts!

Reading the latest workouts

Up next, we'll read all cycling workouts, which is achieved by using our existing store to query for all workouts with activity type matching cycling. We will not set a limit for how many workouts we want to be returned, and sort the workouts by start date descending, so we get the most recent workout first.

The flow to retrieve data from HealthKit is to formulate a query, then execute it using the store instance. The query itself contains a resultsHandler, which is invoked once the results are ready. To make it compatible with async/await, we'll use a CheckedContinuation to bridge between the callback-style code.

func readWorkouts() async -> [HKWorkout]? {
    let cycling = HKQuery.predicateForWorkouts(with: .cycling)

    let samples = try! await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKSample], Error>) in
        store.execute(HKSampleQuery(sampleType: .workoutType(), predicate: cycling, limit: HKObjectQueryNoLimit,sortDescriptors: [.init(keyPath: \HKSample.startDate, ascending: false)], resultsHandler: { query, samples, error in
            if let hasError = error {
                continuation.resume(throwing: hasError)
                return
            }

            guard let samples = samples else {
                fatalError("*** Invalid State: This can only fail if there was an error. ***")
            }

            continuation.resume(returning: samples)
        }))
    }

    guard let workouts = samples as? [HKWorkout] else {
        return nil
    }

    return workouts
}

Running this function, we can obtain a list of all workouts, with the most recent one being the first item. Now that could already be everything you're interested in, but we can go one step further, loading the route (as a list of coordinates) that made up the workout. Of course, this only really makes sense for outdoor activities like cycling, running, and walking.

Getting the route for a workout

The first step to getting our list of locations making up the route is to load the route sample associated with a specific workout. We use an anchored object query because the route may change over time when new data is available or an application has smoothed it. In this guide, we won't go deeper into handling updates to routes, but you can follow the guide in the documentation to learn more.

func getWorkoutRoute(workout: HKWorkout) async -> [HKWorkoutRoute]? {
    let byWorkout = HKQuery.predicateForObjects(from: workout)

    let samples = try! await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKSample], Error>) in
        store.execute(HKAnchoredObjectQuery(type: HKSeriesType.workoutRoute(), predicate: byWorkout, anchor: nil, limit: HKObjectQueryNoLimit, resultsHandler: { (query, samples, deletedObjects, anchor, error) in
            if let hasError = error {
                continuation.resume(throwing: hasError)
                return
            }

            guard let samples = samples else {
                return
            }

            continuation.resume(returning: samples)
        }))
    }

    guard let workouts = samples as? [HKWorkoutRoute] else {
        return nil
    }

    return workouts
}

With this function, we get all workout route samples associated with the workout. I haven't gotten more than one object back with my personal workouts, but I only recorded workouts via the Apple Watch, so tracking workouts with other applications could still result in multiple routes.

Our route doesn't contain a lot of information yet, so we need to load the locations associated with it. Because a route may contain multiple thousands of locations, we load those in batches and return only once we have a complete list of locations. Depending on the activities your application will interact with, you should really test this for performance hits.

func getLocationDataForRoute(givenRoute: HKWorkoutRoute) async -> [CLLocation] {
    let locations = try! await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[CLLocation], Error>) in
        var allLocations: [CLLocation] = []

        // Create the route query.
        let query = HKWorkoutRouteQuery(route: givenRoute) { (query, locationsOrNil, done, errorOrNil) in

            if let error = errorOrNil {
                continuation.resume(throwing: error)
                return
            }

            guard let currentLocationBatch = locationsOrNil else {
                fatalError("*** Invalid State: This can only fail if there was an error. ***")
            }

            allLocations.append(contentsOf: currentLocationBatch)

            if done {
                continuation.resume(returning: allLocations)
            }
        }

        store.execute(query)
    }

    return locations
}

In our implementation, we simply batch up all locations until we're done, then return them using the continuation.

The returned locations are represented as CoreLocation types, which makes them easy to ingrate with other parts of the Apple developer ecosystem, such as MapKit. You could display the route on a map or use Geocoding to convert the coordinates into more human-friendly descriptors.


That's pretty much all there is to it. We used HealthKit to read workout data, and from there on fetched all recorded locations that were part of the route. We used async/await in combination with CheckedContinuations to connect the latest functionality in Swift with existing core libraries.