iOS 18 and watchOS 11 introduced a new metrics to allow users to record the effort for a workout: a score between 1 and 10 rates the workout from easy to all-out challenging. This metrics contributes to the user’s overall Training Load, as can be seen on a chart in the Fitness app.
Naturally, I want my weight training app Liiift to also allow my users to record that important information. The problem is how: documentation is scarce, and I had to put together this from a few Apple’s official pages that only implied the API calls, as well as a helpful Stackoverflow answer that assembled some of the code. I hope this can help you if you’re looking to do the same!
Asking for Permissions
As with all HealthKit data, you need to request permissions before you’ll be able to read and write (“share”) records. You can add workoutEffortScore
object type to the permission request. This is how I do it, keeping in mind my app supports iOS 16+:
let typesToShare: Set<HKSampleType> = {
var types: Set<HKSampleType> = [
HKQuantityType.workoutType(),
]
if #available(iOS 18.0, watchOS 11.0, *) {
types.insert(HKQuantityType(.workoutEffortScore))
}
return types
}()
I’ve also done something similar to my types-to-read variable, including workoutEffortScore
when the app runs on iOS 18 or watchOS 11.
Note that there is also an estimatedWorkoutEffortScore
object type. Per my testing, Apple’s Fitness app may display the score if and only if no record of workoutEffortScore
(non-estimated type) is available. When the user rates the workout manually in Fitness, the new written record is indeed workoutEffortScore
. This is how they are different:
- Estimated scores are provided automatically by Apple Watch for certain types of workouts (as of today: walking, running, hiking, and cycling). Per a developer forum discussion, “The system is supposed to create estimated workout effort scores (.estimatedWorkoutEffortScore) for certain workout types”.
- When you add a manual entry, use
workoutEffortScore
.
Reading from HealthKit
To read the workout effort score efforts with Concurrency, use HKSampleQueryDescriptor
. Let’s also assume you have queried and fetched the HKWorkout
object from HealthKit. Recall that an HKWorkout
object is akin to a container that associates many other types of HealthKit records (most commonly, workout minutes and active calories). Workout effort scores are similar: they are also separate HealthKit records and get attached to the workout; the only difference is that an effort score entry makes no sense standing on its own.
To query the records, you’ll need to set the sample type to HKQuantityType(.workoutEffortScore)
and the predicate to HKQuery.predicateForWorkoutEffortSamplesRelated(workout: workout, activity: nil)
Put together, this is the code:
@MainActor
@available(iOS 18.0, *)
func effortScore(for workout: HKWorkout) async throws -> Int? {
let sampleType = HKQuantityType(.workoutEffortScore)
let predicate = HKQuery.predicateForWorkoutEffortSamplesRelated(workout: workout, activity: nil)
let descriptor = HKSampleQueryDescriptor(predicates: [
HKSamplePredicate.sample(type: sampleType, predicate: predicate)
], sortDescriptors: [])
let samples = try await descriptor.result(for: healthStore)
if let firstSample = samples.last as? HKQuantitySample {
let doubleValue = firstSample.quantity.doubleValue(for: .appleEffortScore())
return Int(doubleValue)
} else {
return nil
}
}
A question may come to you naturally: there can be multiple effort score entries associated to a workout. Notably, Apple’s Fitness app seems to write a new record every time the user updates it. How do we find which one to use?
In my code above, you’ll notice there’s no sort descriptor specified. Ideally, we should be finding the effort score entry that was last updated/modified/created, but there’s no public API for us to achieve this.
In my testing, however, when I only use the last entry from the fetched list of records, it’s always the most recent record. For now, I’ll stick to this method, but if you know how to sort the records, please let me know!
Finally, I’ve tried to call the HKWorkout
object’s statistics(for:)
method to try to fetch the score. Well… it just doesn’t work! But again, please let me know if I’m wrong. This is the code I’ve tried but quickly scrapped from my app:
let sampleType = HKQuantityType(.workoutEffortScore)
if let doubleValue = workout.statistics(for: sampleType)?.mostRecentQuantity()?.doubleValue(for: .appleEffortScore()) {
return Int(doubleValue)
} else {
return nil
}
Ideally, this should be the way to go, the same way we find a workout’s total calories: it both helps define the most recent record via an official, public API, and allows us to fetch the value in one synchronous call. I’m not sure what led to the decision to not support this 🤷
Writing to HealthKit
Writing to HealthKit follows a similar pattern, except that there’s a new HKHealthStore
method to “relate” the effort score record to a workout:
@MainActor
@available(iOS 18.0, *)
func updateEffortScore(_ newScore: Int, for workout: HKWorkout) async throws {
// Un-relate (delete) any existing effort records
let sampleType = HKQuantityType(.workoutEffortScore)
let predicate = HKQuery.predicateForWorkoutEffortSamplesRelated(workout: workout, activity: nil)
let descriptor = HKSampleQueryDescriptor(predicates: [
HKSamplePredicate.sample(type: sampleType, predicate: predicate)
], sortDescriptors: [])
for sample in try await descriptor.result(for: healthStore) {
// May not delete all records: only app-created entries can be deleted
try? await healthStore.delete(sample)
}
// Relate new effort
let effort = HKQuantitySample(
type: sampleType,
quantity: HKQuantity(unit: .appleEffortScore(), doubleValue: Double(newScore)),
start: workout.startDate,
end: workout.endDate
)
try await healthStore.relateWorkoutEffortSample(effort, with: workout, activity: nil)
}
A few points to unpack:
- To “un-relate” a record of effort score, we attempt to delete the record. Note that HealthKit is designed so that you can only modify and delete the records added by your app: you cannot modify or delete the records added by Apple’s Fitness app, for example. Therefore, we use
try?
to delete as many records as we can. - Instead of deleting, you can also call
unrelateWorkoutEffortSample
and achieve similar results. I don’t see documentations pointing to either of the directions here, so I just decided to delete as many records as I can before adding a new one. Frankly, there’s no need to even un-relate or delete a record at all before relating a new one — we’re all just guessing here.
Parting Thoughts
I hope you’ve found this article helpful in reading/writing workout effort scores! Developer’s documentations have been an ongoing issue and I hope that Apple can improve that, either through better in-line API documentations or by updating example projects that come with key frameworks.