Sitemap
Level Up Coding

Coding tutorials and news. The developer homepage gitconnected.com && skilled.dev && levelup.dev

SwiftUI: Simple Workout App with HealthKit (Part 1)

19 min readMar 15, 2025

--

Sample App on GitHub!

**************

Health Kit is a really cool framework that allows us to build apps able to access and share health and fitness data while maintaining the user’s privacy and control.

Let’s make a simple Workout App to check out some of the basics of this framework. We will be making a WatchOS (Yeah!!!) App this time because (I think) it is what makes the most out of this framework!

In this article, we will be focusing on

  • setting up the framework
  • requesting and managing permissions
  • starting, pausing, resuming, and ending a workout activity, as well as saving it the the HealthKit store
  • monitoring statistics during the workout
  • obtaining the final metrics after workout ended

And! This is just PART 1!

What we will have for the next part? You will find that out at the end of this article!

Overview

HealthKit provides a central data store for health and fitness data on iPhone and Apple Watch. With the user’s permission, we can access the store to read and share those data.

Since different apps will have access to the same store, we should

  • only share data that we don’t mind going out of our app
  • able to handle any changes to the store data outside our app

And health data can get pretty sensitive some times, so we have to get user permission to read data from or write data to the HealthKit store!

And! That’s what we will be doing next!

Set Up

Couple things we need to do here.

  1. Add HealthKit Capability
  2. Add Background Mode for processing workout
  3. Add usage descriptions

Enable HealthKit

Before we can use HealthKit, we must enable the HealthKit capabilities for our app.

Choose the Watch Target!

Click on + Capability under Signing & Capabilities.

Press enter or click to view image in full size

Search for HealthKit and add it to the target.

Press enter or click to view image in full size

We can leave both of the check box empty like following.

Press enter or click to view image in full size

Yes, we do NOT need HealthKit Background Delivery.

This is for executing long-running queries that will update our app whenever the system detects changes to the HealthKit store.

Add Background Modes

Again, for the same target, add the Background Modes Capability and select Workout processing.

Press enter or click to view image in full size

This will allow our app to receive background updates on statistics such as energy burnt and heart rate when a workout session is running.

Add Usage Descriptions

Since we will both be reading user health data, as well as saving in to the HealthKit store, we will need add two keys to our Info.plist of the watch target!

  1. NSHealthShareUsageDescription to explain why our app requested permission to read samples from the HealthKit store
  2. NSHealthUpdateUsageDescription for explaining why we need the write permission to the HealthKit store
Press enter or click to view image in full size

If your app is going to access users clinical data, a little more set up will need to be made as it is more sensitive than other data. You can read more about that under Accessing a User’s Clinical Records.

Workout Manager

Like always, manager class first and I will also be sharing with you a sample view on how we can use this manager in a little bit!

Code

//
// WorkoutManager.swift
// ItsukiWorkoutApp
//
// Created by Itsuki on 2025/03/02.
//


import SwiftUI
import HealthKit


@MainActor
@Observable
class WorkoutManager: NSObject {

let supportedWorkoutTypes: Set<HKWorkoutActivityType> = [
.walking,
.running,
.cycling,
.swimming,
.swimBikeRun,
.highIntensityIntervalTraining,
]

private let typesToShare: Set<HKSampleType> = [HKQuantityType.workoutType()]

private let typesToRead: Set<HKObjectType> = [
HKQuantityType(.heartRate),
HKQuantityType(.activeEnergyBurned),

HKQuantityType(.stepCount),
HKQuantityType(.swimmingStrokeCount),
HKQuantityType(.walkingSpeed),
HKQuantityType(.cyclingSpeed),
HKQuantityType(.runningSpeed),

HKQuantityType(.distanceWalkingRunning),
HKQuantityType(.distanceCycling),
HKQuantityType(.distanceSwimming),

HKObjectType.activitySummaryType()
]


var error: WorkoutError? {
didSet {
if let error {
print("error: \(error.message)")
}
}
}

// after session end
var showResult: Bool = false {
didSet {
if !showResult {
reset()
return
}
}
}
var workoutResult: HKWorkout? {
didSet {
if workoutResult != nil {
self.showResult = true
}
}
}

// during the session
var sessionRunning: Bool = false
var workoutMetrics: WorkoutMetrics?
func getElapseTime(at date: Date) -> TimeInterval {
return self.builder?.elapsedTime(at: date) ?? 0
}

private var healthStore: HKHealthStore?

private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
// for iOS: use HKWorkoutBuilder instead.
// var builder: HKWorkoutBuilder?

override init() {
super.init()
if HKHealthStore.isHealthDataAvailable() {
self.healthStore = HKHealthStore()
Task {
await self.requestAuthorization()
}
} else {
self.error = .unavailable
}
}

}


// MARK: - Permission related

extension WorkoutManager {
private func checkAvailability() async -> Bool {
if !HKHealthStore.isHealthDataAvailable() {
self.error = .unavailable
return false
}

do {
let status = try await healthStore?.statusForAuthorizationRequest(toShare: typesToShare, read: typesToRead)
if status == .unnecessary {
return true
}

if status == .shouldRequest {
await self.requestAuthorization()
return true
}

self.error = .permissionDenied
return false

} catch(let error) {
self.error = .requestPermissionError(error)
return false
}
}

private func requestAuthorization() async {
do {
try await healthStore?.requestAuthorization(toShare: typesToShare, read: typesToRead)
} catch (let error) {
self.error = .requestPermissionError(error)
}
}

}


// MARK: - Manage workout session/builder
extension WorkoutManager {

func startWorkout(with configuration: HKWorkoutConfiguration) async {
let result = await self.checkAvailability()
if !result {
return
}
guard let healthStore else {
self.error = .unavailable
return
}

if !self.supportedWorkoutTypes.contains(configuration.activityType) {
self.error = .workoutTypeNotSupported
return
}


do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
builder = session?.associatedWorkoutBuilder()
} catch(let error) {
self.error = .startWorkoutFailed(error)
return
}
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
// print("types to collect: ", builder?.dataSource?.typesToCollect as Any)

// to enable additional types to collect, ex: respiratoryRate
// builder?.dataSource?.enableCollection(for: HKQuantityType(.respiratoryRate), predicate: nil)

// To monitor session and builder updates
session?.delegate = self
builder?.delegate = self

// start activity
let date = Date()

session?.startActivity(with: date)

do {
try await builder?.beginCollection(at: date)
self.workoutMetrics = .init(workoutConfiguration: configuration)
} catch(let error) {
self.error = .startWorkoutFailed(error)
reset()
}
}

func resumeSession() {
session?.resume()
}

func pauseSession() {
session?.pause()
}

func endSession() {
session?.end()
}

private func reset() {
self.session = nil
self.builder = nil
self.workoutResult = nil
self.workoutMetrics = nil
}

}


// MARK: - other helper functions

extension WorkoutManager {
nonisolated private func setError(_ error: WorkoutError) {
DispatchQueue.main.async {
self.error = error
}
}

nonisolated private func updateMetrics(_ statistics: HKStatistics?) {
guard let statistics else { return }

// print("update metrics for \(statistics.quantityType.identifier)")
DispatchQueue.main.async {
switch statistics.quantityType {

case HKQuantityType(.heartRate):
self.workoutMetrics?.heartRate = statistics.heartRate
self.workoutMetrics?.averageHeartRate = statistics.averageHeartRate

case HKQuantityType(.activeEnergyBurned):
self.workoutMetrics?.energyBurned = statistics.activeEnergyBurned
case HKQuantityType(.distanceWalkingRunning),
HKQuantityType(.distanceCycling),
HKQuantityType(.distanceSwimming):
self.workoutMetrics?.distance = statistics.totalDistance

case HKQuantityType(.walkingSpeed),
HKQuantityType(.cyclingSpeed),
HKQuantityType(.runningSpeed):
self.workoutMetrics?.speed = statistics.speed
self.workoutMetrics?.averageSpeed = statistics.averageSpeed


case HKQuantityType(.swimmingStrokeCount):
self.workoutMetrics?.stepStrokeCount = statistics.strokeCount

case HKQuantityType(.stepCount):
self.workoutMetrics?.stepStrokeCount = statistics.stepCount

default:
return
}
}
}
}


// MARK: - HKWorkoutSessionDelegate

extension WorkoutManager: HKWorkoutSessionDelegate {
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {

DispatchQueue.main.async {
self.sessionRunning = (toState == .running)
}
// Wait for the session to transition states before ending the builder.
if toState == .ended {
Task {
do {
try await builder?.endCollection(at: date)
let workout = try await builder?.finishWorkout()
DispatchQueue.main.async {
self.workoutResult = workout
}
} catch(let error) {
self.setError(.endWorkFailed(error))
return
}

}
}
}


// called when an error occurs that stops a workout session.
// When the state of the workout session changes due to an error occurring, this method is always called before workoutSession:didChangeToState:fromState:date:.
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: any Error) {
setError(.sessionStoppedWithError(error))
DispatchQueue.main.async(execute: {
self.endSession()
self.reset()
})
}


// used when divided the session into multiple actives, for example, for a multi-sport event
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didBeginActivityWith workoutConfiguration: HKWorkoutConfiguration, date: Date) {
print("did begin activity: \(workoutConfiguration)")

}

nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didEndActivityWith workoutConfiguration: HKWorkoutConfiguration, date: Date) {
print("did end activity: \(workoutConfiguration)")
}

}


// MARK: - HKLiveWorkoutBuilderDelegate

extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return
}
let statistics = workoutBuilder.statistics(for: quantityType)
updateMetrics(statistics)
}

}

nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
print("did collect event: last new event: \(String(describing: workoutBuilder.workoutEvents.last))")
if let event = workoutBuilder.workoutEvents.last {
DispatchQueue.main.async(execute: {
switch event.type {
case .pauseOrResumeRequest:
print("pauseOrResumeRequest received.")
self.sessionRunning ? self.pauseSession() : self.resumeSession()
break
default:
break
}
})
}
}

nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didBegin workoutActivity: HKWorkoutActivity) {
print("did begin activity: \(workoutActivity)")
}

nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didEnd workoutActivity: HKWorkoutActivity) {
print("did end activity: \(workoutActivity)")

}

}


// MARK: - Types

extension WorkoutManager {
enum WorkoutError: Error {
case unavailable
case permissionDenied
case requestPermissionError(Error)
case workoutTypeNotSupported
case startWorkoutFailed(Error)
case endWorkFailed(Error)
case sessionStoppedWithError(Error)
case queryError(Error?)

var message: String {
switch self {
case .unavailable:
return "Workout unavailable."
case .permissionDenied:
return "Permission denied."
case .requestPermissionError(let error):
return "Request permission: \(error.localizedDescription)"
case .workoutTypeNotSupported:
return "Workout type not supported."
case .startWorkoutFailed(let error):
return "startWorkoutFailed: \(error.localizedDescription)"
case .endWorkFailed(let error):
return "endWorkFailed: \(error.localizedDescription)"
case .sessionStoppedWithError(let error):
return "sessionStoppedWithError: \(error.localizedDescription)"
case .queryError(let error):
return "queryError: \(error?.localizedDescription ?? "unknown")"

}
}
}

struct WorkoutMetrics: Hashable, Identifiable, Equatable {
var id: UUID = UUID()

var workoutConfiguration: HKWorkoutConfiguration

var averageHeartRate: Double = 0
var heartRate: Double = 0
var energyBurned: Double = 0

var distance: Double = 0
var speed: Double = 0
var averageSpeed: Double = 0

var stepStrokeCount: Double = 0

static func == (lhs: Self, rhs: Self) -> Bool {
return lhs.workoutConfiguration == rhs.workoutConfiguration
}

func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
}


extension HKStatistics {
static var heartRateUnit: HKUnit {
HKUnit.count().unitDivided(by: HKUnit.minute())
}

static var energyUnit: HKUnit {
HKUnit.kilocalorie()
}

static var distanceUnit: HKUnit {
HKUnit.meter()
}

static var speedUnit: HKUnit {
HKUnit.meter().unitDivided(by: HKUnit.second())
}

static var countUnit: HKUnit {
HKUnit.count()
}

var heartRate: Double {
if self.quantityType == HKQuantityType(.heartRate) {
return self.mostRecentQuantity()?.doubleValue(for: Self.heartRateUnit) ?? 0
}
return 0
}

var averageHeartRate: Double {
if self.quantityType == HKQuantityType(.heartRate) {
return self.averageQuantity()?.doubleValue(for: Self.heartRateUnit) ?? 0
}
return 0
}

var activeEnergyBurned: Double {
if self.quantityType == HKQuantityType(.activeEnergyBurned) {
return self.sumQuantity()?.doubleValue(for: Self.energyUnit) ?? 0
}
return 0
}

var totalDistance: Double {
if self.quantityType == HKQuantityType(.distanceWalkingRunning) ||
self.quantityType == HKQuantityType(.distanceCycling) ||
self.quantityType == HKQuantityType(.distanceSwimming) {
return self.sumQuantity()?.doubleValue(for: Self.distanceUnit) ?? 0
}
return 0
}

var speed: Double {
if self.quantityType == HKQuantityType(.walkingSpeed) ||
self.quantityType == HKQuantityType(.runningSpeed) ||
self.quantityType == HKQuantityType(.cyclingSpeed) {
return self.mostRecentQuantity()?.doubleValue(for: Self.speedUnit) ?? 0
}
return 0
}

var averageSpeed: Double {
if self.quantityType == HKQuantityType(.walkingSpeed) ||
self.quantityType == HKQuantityType(.runningSpeed) ||
self.quantityType == HKQuantityType(.cyclingSpeed) {
return self.averageQuantity()?.doubleValue(for: Self.speedUnit) ?? 0
}
return 0
}

var stepCount: Double {
if self.quantityType == HKQuantityType(.stepCount) {
return self.sumQuantity()?.doubleValue(for: Self.countUnit) ?? 0
}
return 0
}

var strokeCount: Double {
if self.quantityType == HKQuantityType(.swimmingStrokeCount) {
return self.sumQuantity()?.doubleValue(for: Self.countUnit) ?? 0
}
return 0
}
}

Check Availability and Request Permission

First of all, before we can even create a HKHealthStore, the access point for all data managed by HealthKit, we will need to check whether HealthKit is available on the user’s device or not by calling the isHealthDataAvailable() class method.

If it is indeed available, we can then request permission to save and read the data types we are interested in by calling requestAuthorization(toShare:read:).

override init() {
super.init()
if HKHealthStore.isHealthDataAvailable() {
self.healthStore = HKHealthStore()
Task {
await self.requestAuthorization()
}
} else {
self.error = .unavailable
}
}

private func requestAuthorization() async {
do {
try await healthStore?.requestAuthorization(toShare: typesToShare, read: typesToRead)
} catch (let error) {
self.error = .requestPermissionError(error)
}
}

The typesToShare parameter here contains the data types we want to share. It can be any concrete subclass of the HKSampleType class. That is any of the HKQuantityType, HKCategoryType, HKWorkoutType, or HKCorrelationType.

We will only be sharing HKWorkoutType here. This type will include all the necessary information and statistics collected from a workout, the duration, distance, energy burnt and etc. Therefore, we don’t need other HKQuantityTypes individually.

typesToRead is a set containing the data types we want to read. It can be any concrete subclass of the HKObjectType class, ie: HKCharacteristicType , HKQuantityType, HKCategoryType, HKWorkoutType, or HKCorrelationType.

We have added couple HKQuantityTypes and an HKActivitySummaryType here. Feel free to add more based on the workout activities you want to support.

You should see the following prompt asking for access on app’s first launch.

In addition to checking availability and requesting for permission on initialization, we will also be checking the request status before we start a workout.

private func checkAvailability() async -> Bool {
if !HKHealthStore.isHealthDataAvailable() {
self.error = .unavailable
return false
}

do {
let status = try await healthStore?.statusForAuthorizationRequest(toShare: typesToShare, read: typesToRead)
if status == .unnecessary {
return true
}

if status == .shouldRequest {
await self.requestAuthorization()
return true
}

self.error = .permissionDenied
return false

} catch(let error) {
self.error = .requestPermissionError(error)
return false
}
}

Start Workout

Now that we have the necessary permissions, we can start a workout by

  1. Creating a HKWorkoutSession
  2. Retrieving the HKLiveWorkoutBuilder from the session.
  3. Assigning HKLiveWorkoutDataSource to the workout builder
  4. Assigning HKWorkoutSessionDelegate to monitor workout session
  5. Assigning HKLiveWorkoutBuilderDelegate to monitor workout builder
  6. Start the session and begin data collection
func startWorkout(with configuration: HKWorkoutConfiguration) async {
let result = await self.checkAvailability()
if !result {
return
}
guard let healthStore else {
self.error = .unavailable
return
}

if !self.supportedWorkoutTypes.contains(configuration.activityType) {
self.error = .workoutTypeNotSupported
return
}

do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
builder = session?.associatedWorkoutBuilder()
} catch(let error) {
self.error = .startWorkoutFailed(error)
return
}
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
// print("types to collect: ", builder?.dataSource?.typesToCollect as Any)

// to enable additional types to collect, ex: respiratoryRate
// builder?.dataSource?.enableCollection(for: HKQuantityType(.respiratoryRate), predicate: nil)

// To monitor session and builder updates
session?.delegate = self
builder?.delegate = self

// start activity
let date = Date()
session?.startActivity(with: date)

do {
try await builder?.beginCollection(at: date)
self.workoutMetrics = .init(workoutConfiguration: configuration)
} catch(let error) {
self.error = .startWorkoutFailed(error)
reset()
}
}

That’s a lot of stuff in one function!

We will be diving into the delegate functions a little more in couple seconds but let’s first take a more detailed look at other parts.

First of all, to start a HKWorkoutSession, we need two things, our HKHealthStore and this HKWorkoutConfiguration we get from the function parameter, the configuration object for the workout. We use it to set the type of activity and the location for the workout so that Apple Watch can optimize both the sensors and the calorimetry based on the configuration for us.

let configuration = HKWorkoutConfiguration()
configuration.activityType = .running
configuration.locationType = .outdoor

activityType specifies the HKWorkoutActivityType of the session.

Other parameters we need to set will depending on the type here. For example, for swimming , instead of locationType, we need swimmingLocationType. And if the swimmingLocationType is set to pool , we will also need to specify lapLength.

We can then create our HKWorkoutSession, retrieve the HKLiveWorkoutBuilder, and assign the dataSource.

Also, apple Watch automatically collects a listed data types based on the activityType of the configuration. For example, an outdoor running session collects and saves activeEnergyBurned, basalEnergyBurned, heartRate, and distanceWalkingRunning.

Get Itsuki’s stories in your inbox

Join Medium for free to get updates from this writer.

We can find out the quantity type samples that the data source automatically sends to the workout builder by observing the builder.dataSource.typesToCollect property.

We can also tell the data source to start collecting a specific data type by calling the data source’s enableCollection(for:predicate:) method, and stop collecting by calling the disableCollection(for:) method. For example, if we want to respiratoryRate in addition, we can add the following after assigning the data source.

builder?.dataSource?.enableCollection(for: HKQuantityType(.respiratoryRate), predicate: nil)

We will be taking a look at the delegates shortly, but for now, by default, the workout session automatically forwards all events to the builder, so both delegates should receive the same set of events. However, we can set the builder’s shouldCollectWorkoutEvents property to false to control the events set to the builder.

After all the configurations, we finally called startActivity(with:) and beginCollection(at:) to start the workout session activity, and begin building the workout.

Our workoutMetrics is just a custom struct that will contain related statistics as the workout session running.

Pause and Resume Session

One liners!

Simply call the pause() and resume() method on the HKWorkoutSession.

func resumeSession() {
session?.resume()
}

func pauseSession() {
session?.pause()
}

However, where we need to use those functions is more than just from the view we will be making ourselves. Where else? We will see!

Finish Workout (Step 1)

Stop a workout session is, again, a multi-step task.

However, different from when we start a session, they cannot be performed in sequence, ie: all within a single function. And the first step to that is to call end() to end the HKWorkoutSession.

func endSession() {
session?.end()
}

We will perform the rest, ending the builder and finish the workout, when we have confirmed that the session is indeed ended within our delegate functions.

HKWorkoutSessionDelegate

HKWorkoutSessionDelegate allows us to receive updates

  • on the session’s state changes,
  • when an event occurs,
  • or the session fails due to an error.

It provides the following functions for tracking the HKWorkoutSession with the first two being required.

We will mainly be focusing on the first one here where we will

  1. Set the sessionRunning variable to indicate whether the session is currently running or not
  2. If the HKWorkoutSessionState is ended indicating the workout session has ended, we will call builder’s endCollection(withEnd:) and finishWorkout() methods to finish the workout.
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {

DispatchQueue.main.async {
self.sessionRunning = (toState == .running)
}
// Wait for the session to transition states before ending the builder.
if toState == .ended {
Task {
do {
try await builder?.endCollection(at: date)
let workout = try await builder?.finishWorkout()
DispatchQueue.main.async {
self.workoutResult = workout
}
} catch(let error) {
self.setError(.endWorkFailed(error))
return
}

}
}
}

The finishWorkout() methods returns an HKWorkout object that we can use to get relative information about the workout. We will see more on how to do that when we move onto creating our view.

HKLiveWorkoutBuilderDelegate

HKLiveWorkoutBuilderDelegate is used for monitoring live workout builders, for example, to receive statistic updates (samples) or to get notified on new HKWorkoutEvent.

We have the following delegation methods with the first two being required.

Let’s start with workoutBuilder(_:didCollectDataOf:) and check out how we can process the HKSampleType.

I think that this is something pretty unique to HealthKit and we will also process our HKWorkout data (from above) later in a similar fashion.

nonisolated func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return
}
let statistics = workoutBuilder.statistics(for: quantityType)
updateMetrics(statistics)
}

}

Within our delegate function, we first check whether the type collected is of HKQuantityType, if yes, we will obtain the statistics for it using the workout builder’s statistics(for:) method.

The statistics, of type HKStatistics, is an object that represents the result of calculating the minimum, maximum, average, or sum over a set of samples from the HealthKit store.

To precess it, we will need to

  1. Determine the quantityType of the samples used to calculate these statistics.
  2. Get relevant HKQuantity based on the type
  3. Retrieve the Double value from the quantity by providing a specific HKUnit

extension HKStatistics {
static var heartRateUnit: HKUnit {
HKUnit.count().unitDivided(by: HKUnit.minute())
}

static var energyUnit: HKUnit {
HKUnit.kilocalorie()
}

static var distanceUnit: HKUnit {
HKUnit.meter()
}

static var speedUnit: HKUnit {
HKUnit.meter().unitDivided(by: HKUnit.second())
}

static var countUnit: HKUnit {
HKUnit.count()
}

var heartRate: Double {
if self.quantityType == HKQuantityType(.heartRate) {
return self.mostRecentQuantity()?.doubleValue(for: Self.heartRateUnit) ?? 0
}
return 0
}

var averageHeartRate: Double {
if self.quantityType == HKQuantityType(.heartRate) {
return self.averageQuantity()?.doubleValue(for: Self.heartRateUnit) ?? 0
}
return 0
}

var activeEnergyBurned: Double {
if self.quantityType == HKQuantityType(.activeEnergyBurned) {
return self.sumQuantity()?.doubleValue(for: Self.energyUnit) ?? 0
}
return 0
}

var totalDistance: Double {
if self.quantityType == HKQuantityType(.distanceWalkingRunning) ||
self.quantityType == HKQuantityType(.distanceCycling) ||
self.quantityType == HKQuantityType(.distanceSwimming) {
return self.sumQuantity()?.doubleValue(for: Self.distanceUnit) ?? 0
}
return 0
}

var speed: Double {
if self.quantityType == HKQuantityType(.walkingSpeed) ||
self.quantityType == HKQuantityType(.runningSpeed) ||
self.quantityType == HKQuantityType(.cyclingSpeed) {
return self.mostRecentQuantity()?.doubleValue(for: Self.speedUnit) ?? 0
}
return 0
}

var averageSpeed: Double {
if self.quantityType == HKQuantityType(.walkingSpeed) ||
self.quantityType == HKQuantityType(.runningSpeed) ||
self.quantityType == HKQuantityType(.cyclingSpeed) {
return self.averageQuantity()?.doubleValue(for: Self.speedUnit) ?? 0
}
return 0
}

var stepCount: Double {
if self.quantityType == HKQuantityType(.stepCount) {
return self.sumQuantity()?.doubleValue(for: Self.countUnit) ?? 0
}
return 0
}

var strokeCount: Double {
if self.quantityType == HKQuantityType(.swimmingStrokeCount) {
return self.sumQuantity()?.doubleValue(for: Self.countUnit) ?? 0
}
return 0
}
}

We then use the extension above to update our workoutMetrics within the updateMetrics helper function.

Next!

Our workoutBuilderDidCollectEvent(_:) method.

Thanks to the awesome WatchOS, a widget is automatically created for us under a running session, displaying the elapse time (automatically calculated by some internal system logic), together with a control button to pause and resume the work out.

However, this control button does NOT actually start nor pause the session. (Obviously… because the system has no idea on how we implemented those functions within our app!)

What it does is to send a HKWorkoutEvent to our app with event type (HKWorkoutEventType) being pauseOrResumeRequest.

To link the control with our app and actually pause or resume the session, we will need to handle it within our workoutBuilderDidCollectEvent(_:) method.

nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
if let event = workoutBuilder.workoutEvents.last {
DispatchQueue.main.async(execute: {
switch event.type {
case .pauseOrResumeRequest:
self.sessionRunning ? self.pauseSession() : self.resumeSession()
break
default:
break
}
})
}
}

Elapse Time

Above are all the main functions to handle workout session and related data updates!

However, there is one last bit I would like to point out! Our getElapseTime function.

To get the elapsed duration of a workout, there are two options here.

  1. The elapsedTime property of HKLiveWorkoutBuilder, and
  2. The elapsedTime(at:) function of HKWorkoutBuilder

The main difference here is that the first property returns the elapsed duration of the workout as constructed thus far, considering paused time, whereas the second function will give us the duration without periods when the workout was paused, which are the intervals between pause and resume events.

View

Time for a view!

import SwiftUI
import HealthKit

struct NewWorkoutView: View {
@Environment(WorkoutManager.self) private var workoutManager
@State private var selectedWorkoutType: HKWorkoutActivityType? = nil
@State private var showConfigurationSheet: Bool = false

@State private var swimLocation: HKWorkoutSwimmingLocationType = .pool
@State private var lapLength: Int = 400
@State private var activityLocation: HKWorkoutSessionLocationType = .outdoor

var body: some View {
@Bindable var workoutManager = workoutManager
let supportedWorkoutTypes = Array(workoutManager.supportedWorkoutTypes)
List {
if let error = workoutManager.error {
Text(error.message)
.foregroundStyle(.red)
}

ForEach(0..<supportedWorkoutTypes.count, id: \.self) { index in
let workoutType = supportedWorkoutTypes[index]
Button(action: {
selectedWorkoutType = workoutType
showConfigurationSheet = true
}, label: {
Text(workoutType.string)
})
}
}
.scrollIndicators(.hidden)
.sheet(isPresented: $showConfigurationSheet, content: {

VStack(alignment: .leading, spacing: 8) {
if let selectedWorkoutType {
if selectedWorkoutType == .swimming {
List {
Picker(selection: $swimLocation, content: {
Text("Open water")
.tag(HKWorkoutSwimmingLocationType.openWater)
Text("Pool")
.tag(HKWorkoutSwimmingLocationType.pool)

}, label: {
Text("Swim Location")
})

}
.frame(height: 56)
.scrollIndicators(.hidden)
.scrollDisabled(true)


HStack {
Text("Lap Length(m)")
.padding(.leading, 4)
.lineLimit(1)
.minimumScaleFactor(0.7)
TextField("", value: $lapLength, format: .number)
}


} else {
Form {
Picker(selection: $activityLocation, content: {
Text("Indoor")
.tag(HKWorkoutSessionLocationType.indoor)
Text("Outdoor")
.tag(HKWorkoutSessionLocationType.outdoor)

}, label: {
Text("Activity Location")
})

}
}

if let error = workoutManager.error {
Text(error.message)
.foregroundStyle(.red)
.padding(.leading, 4)
.lineLimit(1)
.minimumScaleFactor(0.8)
}

HStack(spacing: 8) {
Button(action: {
showConfigurationSheet = false
}, label: {
Text("Cancel")
.foregroundStyle(.red)
.padding(.all, 4)
.contentShape(Rectangle())

})

Button(action: {
Task {
let configuration = HKWorkoutConfiguration()
configuration.activityType = selectedWorkoutType
if selectedWorkoutType == .swimming {
configuration.swimmingLocationType = swimLocation
configuration.lapLength = HKQuantity(unit: HKUnit.meter(), doubleValue: Double(lapLength))
} else {
configuration.locationType = activityLocation
}
await workoutManager.startWorkout(with: configuration)
showConfigurationSheet = false
}

}, label: {
Text("Start")
.foregroundStyle(.blue)
.padding(.all, 4)
.contentShape(Rectangle())
})

}
.padding(.trailing, 4)
.fontWeight(.semibold)
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .trailing)

}
}
.font(.system(size: 12))
.frame(maxHeight: .infinity, alignment: .top)
.toolbarVisibility(.hidden, for: .navigationBar)
.interactiveDismissDisabled()
})
.onChange(of: showConfigurationSheet, {
if !showConfigurationSheet {
selectedWorkoutType = nil
}
})
.navigationTitle("New Workout")
.navigationDestination(item: $workoutManager.workoutMetrics, destination: { _ in
WorkoutProgressView()
.environment(workoutManager)
})
.sheet(isPresented: $workoutManager.showResult) {
WorkoutResultView()
.environment(workoutManager)
.toolbarVisibility(.hidden, for: .navigationBar)
}
}
}

struct WorkoutProgressView: View {
@Environment(WorkoutManager.self) private var workoutManager
@State private var currentDate: Date = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

@State private var showControl: Bool = false
@State private var showProgressView: Bool = false

var body: some View {

ZStack {

if showProgressView {
ProgressView()
.controlSize(.extraLarge)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}

if let metrics = workoutManager.workoutMetrics {
VStack(alignment: .leading) {
HStack {
Text(workoutManager.getElapseTime(at: currentDate).hourMinuteSecond)
.font(.title2)
.fontWeight(.semibold)
Spacer()


Button(action: {
showControl.toggle()
}, label: {
Image(systemName: "ellipsis")
.rotationEffect(.degrees(showControl ? 90 : 0))
.frame(width: 32, height: 32)
.background(Circle().fill(.gray))
})
.overlay(alignment: .top, content: {
if showControl {
let sessionRunning = workoutManager.sessionRunning
VStack(spacing: 8) {
Button(action: {
sessionRunning ? workoutManager.pauseSession() : workoutManager.resumeSession()
}, label: {
Image(systemName: sessionRunning ? "pause.fill" : "arrow.clockwise")
.frame(width: 32, height: 32)
.background(Circle().fill(.gray))
})

Button(action: {
workoutManager.endSession()
self.showProgressView = true
}, label: {
Image(systemName: "stop.fill")
.frame(width: 32, height: 32)
.background(Circle().fill(.gray))
})

}
.padding(.top, 40)
}
})
.buttonStyle(.plain)
}

Text(metrics.distance.formatted(.number.precision(.fractionLength(0))) + " \(HKStatistics.distanceUnit.unitString)")
Text(metrics.energyBurned.formatted(.number.precision(.fractionLength(0))) + " \(HKStatistics.energyUnit.unitString)")
Text(metrics.heartRate.formatted(.number.precision(.fractionLength(0))) + " bpm")
if metrics.workoutConfiguration.activityType == .swimming {
Text(metrics.stepStrokeCount.formatted(.number.precision(.fractionLength(0))) + " strokes")
}
if metrics.workoutConfiguration.activityType == .walking || metrics.workoutConfiguration.activityType == .running || metrics.workoutConfiguration.activityType == .highIntensityIntervalTraining {
Text(metrics.stepStrokeCount.formatted(.number.precision(.fractionLength(0))) + " steps")
}
}
.onReceive(timer) { input in
currentDate = input
}
.disabled(showProgressView)
.navigationTitle("\(metrics.workoutConfiguration.activityType.string)")
.navigationBarTitleDisplayMode(.inline)
}
}
.navigationBarBackButtonHidden(true)
.onTapGesture {
self.showControl = false
}
}
}

struct WorkoutResultView: View {
@Environment(WorkoutManager.self) private var workoutManager

var body: some View {

if let result = workoutManager.workoutResult {
ScrollView {

HKWorkoutView(workout: result)

Spacer()
.frame(height: 8)

Button(action: {
workoutManager.showResult = false
}, label: {
Text("Done")
})

}
}
}
}


struct HKWorkoutView: View {
var workout: HKWorkout

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("\(workout.workoutActivityType.string) Summary")
.font(.title3)
.fontWeight(.bold)

Text("**Duration:** \(workout.duration.hourMinuteSecond)" )


let distanceStatistics = switch workout.workoutActivityType {
case .cycling:
workout.statistics(for: HKQuantityType(.distanceCycling))
case .swimming:
workout.statistics(for: HKQuantityType(.distanceSwimming))
default:
workout.statistics(for: HKQuantityType(.distanceWalkingRunning))
}
if let distanceStatistics {
Text("**Total Distance:** \(distanceStatistics.totalDistance.formatted(.number.precision(.fractionLength(0)))) \(HKStatistics.distanceUnit.unitString)" )
}

if let energyStatistics = workout.statistics(for: HKQuantityType(.activeEnergyBurned)) {
Text("**Energy Burnt:** \(energyStatistics.activeEnergyBurned.formatted(.number.precision(.fractionLength(0)))) \(HKStatistics.energyUnit.unitString)" )
}

if let heartRateStatistics = workout.statistics(for: HKQuantityType(.heartRate)) {
Text("**Avg. Heart Rate:** \(heartRateStatistics.averageHeartRate.formatted(.number.precision(.fractionLength(0)))) bpm" )
}

if let stepCountStatistics = workout.statistics(for: HKQuantityType(.stepCount)) {
Text("**Steps:** \(stepCountStatistics.stepCount.formatted(.number.precision(.fractionLength(0))))" )

}

if let strokeCountStatistics = workout.statistics(for: HKQuantityType(.swimmingStrokeCount)) {
Text("**Strokes:** \(strokeCountStatistics.strokeCount.formatted(.number.precision(.fractionLength(0))))" )
}
}
.frame(maxWidth: .infinity, alignment: .leading)

}
}

extension TimeInterval {
var hourMinuteSecond: String {
let interval = Int(self)
let seconds = interval % 60
let minutes = (interval / 60) % 60
let hours = (interval / (60*60)) % 60
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
}

extension HKWorkoutActivityType {

var string: String {
switch self {
case .walking:
return "Walking"
case .running:
return "Running"
case .cycling:
return "Cycling"
case .swimming:
return "Swimming"
case .swimBikeRun:
return "Triathlon"
case .highIntensityIntervalTraining:
return "Interval Training"
default:
return "(not supported)"
}
}
}

The only point (and our last point for today!) I would like to have your attention here is how we obtain the HKStatistics from the HKWorkout!

Similar to the statistics(for:) method on the builder, we also have a statistics(for:) on the HKWorkout! We pass in the HKQuantityType we are interested in, and we get our HKStatistics!

From there, the statistic processing will just be the exact same as above!

That’s it!

Wear your watch and go for a run!

Yap! I went on a 7 seconds (technically speaking, maybe only 6?) cycling!

That’s it for part 1!

Again, feel free to grab the demo app from my GitHub!

Above is great, but don’t we want to know how we are doing for the day? How much we have worked out? How many energy we have burnt?

That’s what we will be doing in Part 2 (Coming soon)!

Check it out if you are interested!

Happy workout-ing!

--

--