Sitemap

SwiftUI/HealthKit: Dividing Workout into SubActivities

9 min readMar 20, 2025

--

Part 3 of our Simple Workout App!

Here is what we have til now!

We can start, pause, resume, and end our workout as well as check out the statistics during the work out and the final metrics after finished!

We can check out our daily results!

However, if you have tried a multi-sport such as swimBikeRun, you will realize some statistics, for example, swimmingStrokeCount, are not collected like they would for a regular swimming activity. Also other statistics, obviously, should not be calculated in the same fashion when we swim vs run.

Let’s solve this little problem in this article by further dividing a workout into multiple activities!

We will be continuing on where we have left off in previous parts, so grab code there or from my GitHub to get started!

Overview

What are some of the scenarios that we might benefit from dividing a workout into multiple sub-activities?

I have already mentioned one above, a multi-sports workout such as swimBikeRun, or triathlon, but let’s take a little more detailed look into it as our little motivation for today!

Speaking at a super high level, there are mainly 2 cases where we might want to further divide a workout.

  1. A multi-sports workout. By dividing each individual sports into its own activity and adding transitions between these activities, we can have HealthKit collect more accurate and targeted statistics, for example, removing resting period when calculating average, collecting strokes on swimming and steps on running, and etc.
  2. Interval training. We can divide each interval into its own activity so that we can visualize and compare the statistics for different intervals.

In addition to be able to better model the workout itself, there are also other benefits of adding corresponding sub-activities, especially in the case of multi-sporting.

When we read data from the HealthKit store, for example, querying for running type with predicateForWorkoutActivities(workoutActivityType:), we will not only able to obtain workouts that use HKWorkoutActivityType.running, but also HKWorkoutActivityType.swimBikeRun workouts that include a running activity.

Implementation

To model the sub-activities or intervals, HealthKit uses the HKWorkoutActivity class.

HealthKit, not us! We will not be creating an HKWorkoutActivity object directly.

Instead, we add a sub-activity of a specific HKWorkoutConfiguration by calling beginNewActivity(configuration:date:metadata:) method on the HKWorkoutSession that we have previously called startActivity(with:) previously with to start a workout.

To end a sub-activity, we have the endCurrentActivity(on:) method.

Actually, HealthKit will end the current activity automatically when we begin a new activity. Then what are some of the cases we might want to call this function manually? This is pretty useful for modeling interval trainings when we want to add blank time between the end of the current activity and the start of the next.

Important

We CANNOT beginNewActivity with any random HKWorkoutConfiguration and expect it to work!

In the case of a multi-sport workout, taking our swimBikeRun as the example here again, we can only start a new activity with the following HKWorkoutActivityType

In the case of an interval training, any HKWorkoutActivityType can be turned in to an interval workout. The only thing here is that all workout activities must use the same activity type as the containing workout. We can’t mix activities within a single workout.

Start and End SubActivity

First of all, let’s add a beginNewSubActivity function and a endSubActivity functions to our WorkoutManager to check out how we can turn the ideas above into code!

import SwiftUI
import HealthKit


@MainActor
@Observable
class WorkoutManager: NSObject {

// ... other stuff from part 1 and 2

func beginNewSubActivity(with configuration: HKWorkoutConfiguration) async {
guard let session else {
self.error = .sessionNotExists
return
}

let originalActivity = workoutMetrics?.workoutConfiguration.activityType
let activityType = configuration.activityType

if originalActivity != .swimBikeRun && activityType != originalActivity {
self.error = .workoutTypeNotSupported
return
}

if originalActivity == .swimBikeRun && activityType != .swimming && activityType != .cycling && activityType != .running && activityType != .transition {
self.error = .workoutTypeNotSupported
return

}

session.beginNewActivity(configuration: configuration, date: Date(), metadata: nil)
self.workoutMetrics?.subActivityConfiguration = configuration
}

func endSubActivity() async {
guard let session else {
self.error = .sessionNotExists
return
}

session.endCurrentActivity(on: Date())
self.workoutMetrics?.subActivityConfiguration = nil
}

}

As you can see, it is really simple! (I have left out other parts of the WorkoutManager as we have already covered it in my previous articles, but if you want a reference to the full code, feel free to grab it from my GitHub!)

Get Itsuki’s stories in your inbox

Join Medium for free to get updates from this writer.

All we have here are checking whether the configuration has the correct type and calling beginNewActivity(configuration:date:metadata:).

Different from starting a workout, we don’t have to anything with the HKLiveWorkoutBuilder here.

Usage In View

Now, let’s check out how we can use the functions above in views to allow our user start or end a sub-activity.

Remember our WorkoutProgressView? We will be adding the logic there because we can ONLY start a sub-activity after starting the container workout!


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

@State private var selectedWorkoutType: HKWorkoutActivityType? = nil
@State private var showNewSubActivitySheet: Bool = false
@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 {

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)
}
})
.overlay(alignment: .trailing, content: {
if showControl {

if metrics.subActivityConfiguration != nil && metrics.workoutConfiguration.activityType != .swimBikeRun {
Button(action: {
Task {
await workoutManager.endSubActivity()
showControl = false
}
}, label: {
Text("End Interval")
.font(.system(size: 12))
.frame(width: 108, height: 32)
.background(Capsule().fill(.gray))
})
.padding(.trailing, 40)

} else {
Button(action: {
if metrics.workoutConfiguration.activityType == .swimBikeRun {
showNewSubActivitySheet = true
return
}

Task {
await workoutManager.beginNewSubActivity(with: metrics.workoutConfiguration)
if workoutManager.error == nil {
showControl = false
}
}
return

}, label: {
Text(metrics.workoutConfiguration.activityType == .swimBikeRun ? "New SubActivity" : "New Interval")
.font(.system(size: 12))
.frame(width: 108, height: 32)
.background(Capsule().fill(.gray))
})
.padding(.trailing, 40)

}
}
})
.buttonStyle(.plain)
}

if let subActivity = metrics.subActivityConfiguration {
Text(subActivity.activityType.string)
.font(.system(size: 12))
.opacity(0.5)
.padding(.bottom, 4)
}

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
}
.sheet(isPresented: $showNewSubActivitySheet, content: {
List {
if let error = workoutManager.error {
Text(error.message)
.foregroundStyle(.red)
}
let activityTypes: [HKWorkoutActivityType] = self.getAllowedSubActivities()

ForEach(0..<activityTypes.count, id: \.self) { index in
let workoutType = activityTypes[index]
Button(action: {
if workoutType == .transition {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .transition
configuration.locationType = metrics.workoutConfiguration.locationType
Task {
await workoutManager.beginNewSubActivity(with: configuration)
if workoutManager.error == nil {
showNewSubActivitySheet = false
showControl = false
}
}
return
}

selectedWorkoutType = workoutType
showNewSubActivitySheet = false
showConfigurationSheet = true

}, label: {
Text(workoutType.string)
})
}
}
})
.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
showNewSubActivitySheet = true
}, 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.beginNewSubActivity(with: configuration)
showConfigurationSheet = false
if workoutManager.error != nil {
showNewSubActivitySheet = true
} else {
showControl = 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()
})
.disabled(showProgressView)
.navigationTitle("\(metrics.workoutConfiguration.activityType.string)")
.navigationBarTitleDisplayMode(.inline)
}
}
.navigationBarBackButtonHidden(true)
.onTapGesture {
self.showControl = false
}
.onAppear {
workoutManager.error = nil
}
.onDisappear {
workoutManager.error = nil
}

}

private func getAllowedSubActivities() -> [HKWorkoutActivityType] {
guard let metrics = workoutManager.workoutMetrics else {
return []
}

if metrics.workoutConfiguration.activityType == .swimBikeRun {
let activityTypes: [HKWorkoutActivityType] = (metrics.subActivityConfiguration == nil || metrics.subActivityConfiguration?.activityType != .transition) ? [.swimming, .cycling, .running, .transition] : [.swimming, .cycling, .running]
return activityTypes
}

return metrics.subActivityConfiguration == nil ? [metrics.workoutConfiguration.activityType] : []
}
}

It might look pretty long but it is actually pretty simple!

First of all, if the current activity is swimBikeRun I am allowing the user to choose from swimming, cycling, running, transition (only if currently not transitioning), and specify configurations such as locations and lap length.

Otherwise, instead of choosing a workout and specifying a configuration, we will simply use the container’s configuration to start a new sub-activity or stop it if currently started.

Retrieve Sub-activities Statistics from HKWorkout

Now that our users have added couple activities within the container workout, done all the hard works and finished their workout, how can we get the statistics for each individual sub-activities?

As we have seen in part 1 and part 2, when we finish a workout with the workout builder’s finishWorkout(completion:) method or query with predicateForWorkoutActivities(workoutActivityType:) , we get this HKWorkout object that contains the summary information about the workout. This is also the where we can get information regarding to the sub-activities.

If we want to retrieve the activities we have added along with their statistics, we can

  1. Get a list of sub-activities with the workoutActivities property
  2. Use the statistics(for:) method on the HKWorkoutActivity we are interested in to get the HKStatistics for a specific HKQuantityType

Let’s extend our HKWorkoutView to display those information!


import SwiftUI
import HealthKit

struct HKWorkoutView: View {
var workout: HKWorkout

@State private var showBreakdown: Bool = false

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))))" )
}

if workout.workoutActivities.count > 1 {
Divider()

VStack(alignment: .leading, spacing: 4) {
Button(action: {
showBreakdown.toggle()
}, label: {
HStack {
Text("**Breakdown**")
Spacer()
Image(systemName: showBreakdown ? "chevron.down" : "chevron.left")

}
.frame(maxWidth: .infinity)
})
.contentShape(Rectangle())
.buttonStyle(.plain)

if showBreakdown {
ForEach(0..<workout.workoutActivities.count, id: \.self) { index in
let activity: HKWorkoutActivity = workout.workoutActivities[index]

VStack(alignment: .leading) {
Text("**\(activity.workoutConfiguration.activityType.string)**")
.foregroundStyle(.primary.opacity(0.8))

Text("\(activity.startDate.string(dateStyle: .none, timeStyle: .medium))-\(activity.endDate?.string(dateStyle: .none, timeStyle: .medium) ?? "(none)")")
.font(.system(size: 12))

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

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

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

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

}

if let strokeCountStatistics = activity.statistics(for: HKQuantityType(.swimmingStrokeCount)) {
Text("**Strokes:** \(strokeCountStatistics.strokeCount.formatted(.number.precision(.fractionLength(0))))" )
}

}
.font(.caption2)
.foregroundStyle(.secondary)
}

}
}

}
}
.frame(maxWidth: .infinity, alignment: .leading)

}
}

Time to give our evolved (maybe just by a little?) workout app a try!

Thank you for reading!

This is the end of our Workout App!

If you haven’t already, feel free to check out

as well!

You can also get the demo app from my GitHub!

Happy work-outing!

--

--