Tracking workouts with HealthKit in iOS apps
Learn how to track workouts using HealthKit in a SwiftUI app.
Building a fitness app for iOS presents unique challenges compared to the Apple Watch, which has long been the go-to platform for workout tracking. Apple Watch offers built-in sensors and seamless HealthKit integration, while iPhone and iPad require developers to work around hardware limitations such as the lack of automatic heart-rate monitoring.
Until now, workout tracking through HealthKit has been possible only on Apple Watch. Starting with iOS 26, those same APIs are finally available on iPhone and iPad, with only minor adjustments required for each platform.
In this tutorial, you'll learn how to build a complete workout tracking app for iOS using HealthKit. By the end, you'll have created a functional fitness app that can initiate workout sessions, collect real-time health metrics such as heart rate, calories, and distance, and save workout data to HealthKit.
You'll explore how to set up HealthKit permissions, coordinate workout sessions with multiple HealthKit objects, handle real-time data updates, and build a responsive SwiftUI interface that adapts to different workout states.
Before We Start
To follow this tutorial completely, you should be familiar with:
- SwiftUI fundamentals: You'll be building the user interface with SwiftUI, so understanding the basics of creating views and layouts, state management, and property wrappers is essential.
- Async/await in Swift: The tutorial uses Swift concurrency for managing workout operations
- Basic HealthKit concepts: While we'll explain the specific APIs, familiarity with HealthKit's role in the Apple ecosystem will be helpful.
Requirements:
- Xcode 26 or later;
- A physical iOS device running iOS 26 (HealthKit doesn't work in the Simulator);
- An optional Bluetooth heart rate monitor for testing heart rate functionality.
You don't need a starter project for this tutorial; we'll build everything from scratch. However, you will need to create a new iOS App project in Xcode before beginning.
Step 1 - Setting Up HealthKit Permissions
Before diving into workout tracking, you need to configure your project to request proper HealthKit permissions from the user. HealthKit requires explicit authorization to both read existing health data and write new workout sessions to the user's health database.
First, add the HealthKit capability to your Xcode project by going to your target's Signing & Capabilities tab and clicking the "+ Capability" button, then selecting HealthKit.
Next, add these two entries to your Info.plist file:
- Privacy - Health Share Usage Description, adding a String like: "This app needs access to read your health data to display workout metrics during your exercise sessions."
- Privacy - Health Update Usage Description, with a String like: "This app needs permission to save your workout data to HealthKit so it can be shared with other health apps."
Now, let’s create the WorkoutManager class, this will serve as the central structure throughout the tutorial. It will handle HealthKit permissions, manage workout sessions, and implement the countdown logic. In other words, most of the app’s core functionality will live here.
Let’s start by adding a method to request authorization:
import HealthKit
import Foundation
class WorkoutManager: NSObject, ObservableObject {
// Request permission to read and write health data
private func requestHealthKitPermission() {
// 1.
guard
let hr = HKObjectType.quantityType(forIdentifier: .heartRate),
let kcal = HKObjectType.quantityType(forIdentifier: .activeEnergyBurned),
let dist = HKObjectType.quantityType(forIdentifier: .distanceWalkingRunning)
else {
return
}
// 2.
let read: Set = [hr, kcal, dist, HKObjectType.workoutType()]
let write: Set = [kcal, dist, HKObjectType.workoutType()]
// 3.
healthStore.requestAuthorization(toShare: write, read: read) { success, error in
if let error {
print("HealthKit auth error:", error.localizedDescription)
} else {
print(success ? "HealthKit access granted" : "HealthKit access denied")
}
}
}
}WorkoutManager.swift
- Create quantity type objects for the specific health metrics you want to track: heart rate, active energy (calories), and walking/running distance. The
guardstatement safely unwraps these optional types. - Define two sets of permissions:
readpermissions allow your app to display existing health data and workout history, whilewritepermissions let you save new workout sessions and metrics to HealthKit's centralized database. - Request authorization from the HealthKit store with both read and write permissions. The completion handler indicates whether the user granted access or if any errors occurred.
Step 2 - Understanding the Workout Architecture
Before implementing workout tracking, it's important to understand how HealthKit coordinates workout sessions. A workout session in HealthKit is managed by four main objects that work together:
- HKWorkoutConfiguration: defines the type of the workout (running, cycling, swimming) and the environment (indoor, outdoor)
- HKWorkoutSession: manages the workout's lifecycle (starting, pausing, resuming and ending)
- HKLiveWorkoutBuilder: collects and saves workout data in real-time
- HKLiveWorkoutDataSource: pulls information from sensors so you don’t have to manage them manually
In the WorkoutManager class, now declare the following properties:
import HealthKit
import Foundation
class WorkoutManager: NSObject, ObservableObject {
// 1.
private let healthStore = HKHealthStore()
// 2.
private var workoutSession: HKWorkoutSession?
private var workoutBuilder: HKLiveWorkoutBuilder?
// 3.
@Published var currentState: WorkoutState = .idle
@Published var metrics = WorkoutMetrics()
// 4.
private var startDate: Date?
private var elapsedTimeTimer: Timer?
private func requestHealthKitPermission() { ... }
}WorkoutManager.swift
- Create the
HKHealthStoreinstance, which serves as the main interface to HealthKit for all health data operations. - Declare optional properties for the workout session and builder, which will be initialized when a workout starts.
- Use
@Publishedproperty wrappers to automatically notify SwiftUI views when the workout state or metrics change, enabling reactive UI updates. - Track the workout start time and a timer for updating elapsed time in the UI.
Then create two separate Swift files to hold the metrics and state logic:
// 5.
// Defines the different states a workout can be in
import Foundation
enum WorkoutState {
case idle // No workout is running
case preparing // Workout is being set up
case active // Workout is currently running
case paused // Workout is temporarily stopped
case finished // Workout has been completed and saved
}WorkoutState.swift
// 6.
// Data model to hold all workout metrics in one place
import Foundation
struct WorkoutMetrics {
var elapsedTime: TimeInterval = 0
var heartRate: Double = 0
var isHeartRateAvailable: Bool = false
var totalCalories: Double = 0
var totalDistance: Double = 0
mutating func reset() {
elapsedTime = 0
heartRate = 0
isHeartRateAvailable = false
totalCalories = 0
totalDistance = 0
}
}WorkoutMetrics.swift
- Define an enum to represent all possible workout states, helping manage UI presentation and prevent invalid operations.
- Create a struct to hold all real-time workout metrics that will be displayed in the UI, with a
reset()method to clear data between workouts.
Step 3 - Starting a Workout Session
Now you'll implement the complete workflow for starting a workout, which involves creating the configuration, initializing the session, preparing sensors, and beginning data collection.
Add this method to your WorkoutManager class:
// Starts a new workout session with the specified activity type
func startWorkout(activity: HKWorkoutActivityType = .running, location: HKWorkoutSessionLocationType = .outdoor) async {
// 1.
metrics.reset()
currentState = .preparing
do {
// 2.
let workoutConfig = HKWorkoutConfiguration()
workoutConfig.activityType = activity
workoutConfig.locationType = location
// 3.
let workoutInstance = try HKWorkoutSession(healthStore: healthStore, configuration: workoutConfig)
workoutInstance.delegate = self
// 4.
let dataBuilder = workoutInstance.associatedWorkoutBuilder()
dataBuilder.delegate = self
// 5.
dataBuilder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: workoutConfig)
// 6.
workoutSession = workoutInstance
workoutBuilder = dataBuilder
// 7.
workoutInstance.prepare()
// 8.
await showCountdown()
// 9.
let workoutStartTime = Date()
startDate = workoutStartTime
workoutInstance.startActivity(with: workoutStartTime)
// 10.
try await dataBuilder.beginCollection(at: workoutStartTime)
// 11.
currentState = .active
startElapsedTimeTimer()
print("Workout started successfully")
} catch {
print("Failed to start workout: \(error.localizedDescription)")
currentState = .idle
}
}WorkoutManager.swift
- Reset any metrics from a previous workout and update the state to
preparingto show the user that initialization is in progress. - Create the workout configuration that defines what type of activity this is (running, cycling, walking) and where it's happening (indoor or outdoor).
- Initialize the workout session with the health store and configuration, and set this class as the delegate to receive session state updates.
- Get the associated workout builder from the session, which is responsible for collecting and saving all health data, and set this class as its delegate to receive data updates.
- Attach a live data source to the builder, which automatically monitors sensors and external devices (like Bluetooth heart rate monitors) and feeds data to the builder.
- Store references to both the session and builder as instance properties so they can be accessed throughout the workout lifecycle.
- Call
prepare()to activate sensors and allow external devices to connect, ensuring you don't miss early workout data. - Show a brief countdown to give sensors time to initialize and provide a better user experience.
- Record the start time and officially begin the workout activity, which signals to HealthKit and the system that a workout is in progress.
- Tell the builder to start accumulating health data samples from this point forward.
- Update the state to
activeand start a timer to track elapsed time for the UI.
Step 4 - Implementing the Countdown
The countdown gives HealthKit a moment to initialize sensors before data collection begins, preventing missed data points at the start of the workout.
Now, add this method to your WorkoutManager class:
// Shows a 3-second countdown to give sensors time to activate
private func performCountdown() async {
// 1.
for i in (1...3).reversed() {
print("Starting in \(i)...")
// 2.
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
print("Workout started!")
}WorkoutManager.swift
- Loop through numbers 3, 2, 1 in reverse order to create a countdown effect.
- Use Swift's async
Task.sleepto pause for one second (1 billion nanoseconds) between each number, giving sensors time to warm up.
You'll also need to implement the timer for tracking elapsed time:
// 3.
private func startElapsedTimeTimer() {
elapsedTimeTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self, let startDate = self.startDate else { return }
self.metrics.elapsedTime = Date().timeIntervalSince(startDate)
}
}
// 4.
private func stopElapsedTimeTimer() {
elapsedTimeTimer?.invalidate()
elapsedTimeTimer = nil
}WorkoutManager.swift
- Create a timer that fires every second to calculate and update the elapsed time based on the difference between the current time and the workout start time.
- Stop and clean up the timer when the workout is paused or ended.
Step 5 - Managing Workout States
Your app needs to handle pausing, resuming, and ending workouts. Each state transition requires coordinating with HealthKit and updating the UI accordingly.
Add these methods to your WorkoutManager class:
// 1.
func pauseWorkout() {
workoutSession?.pause()
currentState = .paused
stopElapsedTimeTimer()
print("Workout paused")
}
// 2.
func resumeWorkout() {
workoutSession?.resume()
currentState = .active
startElapsedTimeTimer()
print("Workout resumed")
}
// 3.
func endWorkout() {
guard let session = workoutSession else { return }
session.stopActivity(with: Date())
stopElapsedTimeTimer()
print("Ending workout...")
}WorkoutManager.swift
- Pause the workout by calling
pause()on the session, which temporarily stops data collection while preserving all accumulated metrics, then stop the UI timer. - Resume the workout by calling
resume()on the session to continue data collection, and restart the elapsed time timer. - End the workout by calling
stopActivity(), which allows final metrics to be collected before the session fully stops.
Step 6 - Handling Real-Time Data Updates
As the workout progresses, HealthKit continuously delivers new health data samples. You need to process these updates to display real-time metrics in your UI.
Implement the method that processes each type of health data:
// Updates the UI with new health statistics
private func updateMetrics(for statistics: HKStatistics) {
// 1.
switch statistics.quantityType {
// 2.
case HKQuantityType.quantityType(forIdentifier: .heartRate):
if let heartRateValue = statistics.mostRecentQuantity()?.doubleValue(for: .count().unitDivided(by: .minute())) {
metrics.heartRate = heartRateValue
metrics.isHeartRateAvailable = true
}
// 3.
case HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned):
if let caloriesValue = statistics.sumQuantity()?.doubleValue(for: .kilocalorie()) {
metrics.totalCalories = caloriesValue
}
// 4.
case HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning):
if let distanceValue = statistics.sumQuantity()?.doubleValue(for: .meter()) {
metrics.totalDistance = distanceValue
}
default:
break
}
}- Use a switch statement to handle each type of health data differently based on its quantity type identifier.
- For heart rate, use
mostRecentQuantity()to get the current reading in beats per minute, and mark heart rate as available so the UI knows to display it. - For calories, use
sumQuantity()to get the cumulative total energy burned throughout the workout in kilocalories. - For distance, use
sumQuantity()to get the total distance traveled in meters, which can be converted to kilometers in the UI.
Now, implement the HKLiveWorkoutBuilderDelegate extension to WorkoutManager to receive data updates:
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
// 1.
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>) {
// 2.
for sampleType in collectedTypes {
guard let quantityType = sampleType as? HKQuantityType else { continue }
// 3.
let statistics = workoutBuilder.statistics(for: quantityType)
// 4.
if let stats = statistics {
updateMetrics(for: stats)
}
}
}
}WorkoutManager.swift
- This delegate method is called whenever new health data is collected during the workout, providing you with a set of sample types that have new data available.
- Iterate through each collected sample type and filter for quantity types, which represent numeric health data like heart rate, calories, and distance.
- Retrieve the latest statistics for this quantity type from the workout builder, which aggregates all samples into useful metrics.
- If statistics are available, pass them to the update method to process and display in the UI.
Step 7 - Saving the Workout to HealthKit
When a workout ends, you need to finalize data collection and save the complete workout to HealthKit. This happens through the workout session delegate.
Implement the HKWorkoutSessionDelegate extension to WorkoutManager:
extension WorkoutManager: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date) {
// 1.
if toState == .stopped,
let builder = workoutBuilder {
Task {
do {
// 2.
try await builder.endCollection(at: date)
// 3.
let savedWorkout = try await builder.finishWorkout()
// 4.
workoutSession.end()
// 5.
await MainActor.run {
currentState = .finished
print("Workout saved to HealthKit: \(savedWorkout)")
}
} catch {
print("Failed to save workout: \(error.localizedDescription)")
await MainActor.run {
currentState = .idle
}
}
}
}
}
// 6.
func workoutSession(_ workoutSession: HKWorkoutSession,
didFailWithError error: Error) {
print("Workout session failed: \(error.localizedDescription)")
}
}- This delegate method is called whenever the workout session changes state. We specifically handle the transition to the
stoppedstate, which occurs afterstopActivity()is called. - Stop collecting workout data at the specified end time, which finalizes all accumulated metrics.
- Call
finishWorkout()to process all collected data and save the complete workout to HealthKit's database, making it available to other health apps. - End the workout session, releasing any system resources and external device connections.
- Update the UI state to
finishedon the main actor (main thread) to show the workout completion screen. - Implement the error delegate method to handle any workout session failures gracefully.
Step 8 - Building the SwiftUI Interface
Now you'll create a responsive SwiftUI interface that displays real-time workout metrics and adapts to different workout states.
Create a new SwiftUI view file:
import SwiftUI
struct WorkoutView: View {
// 1.
// Connect to our workout manager to get real-time updates
@StateObject private var workoutManager = WorkoutManager()
var body: some View {
NavigationView {
VStack(spacing: 30) {
// 2.
Text("Workout Tracker")
.font(.largeTitle)
.fontWeight(.bold)
// 3.
VStack(spacing: 20) {
MetricCard(
title: "Duration",
value: formatTime(workoutManager.metrics.elapsedTime),
unit: "",
color: .blue
)
// 4.
if workoutManager.metrics.isHeartRateAvailable {
MetricCard(
title: "Heart Rate",
value: String(format: "%.0f", workoutManager.metrics.heartRate),
unit: "BPM",
color: .red
)
} else if workoutManager.currentState == .active {
VStack {
Image(systemName: "heart.slash")
.font(.title)
.foregroundColor(.orange)
Text("Connect a heart rate monitor")
.font(.caption)
.foregroundColor(.orange)
}
.padding()
.background(Color.orange.opacity(0.1))
.cornerRadius(10)
}
MetricCard(
title: "Calories",
value: String(format: "%.0f", workoutManager.metrics.totalCalories),
unit: "kcal",
color: .orange
)
MetricCard(
title: "Distance",
value: String(format: "%.2f", workoutManager.metrics.totalDistance / 1000),
unit: "km",
color: .green
)
}
.padding(.horizontal)
// 5.
VStack(spacing: 15) {
switch workoutManager.currentState {
case .idle:
Button("Start Running Workout") {
Task {
await workoutManager.startWorkout(activity: .running, location: .outdoor)
}
}
.buttonStyle(StartButtonStyle())
case .preparing:
VStack {
ProgressView()
.scaleEffect(1.2)
Text("Preparing workout...")
.font(.headline)
.foregroundColor(.blue)
}
case .active:
HStack(spacing: 20) {
Button("Pause") {
workoutManager.pauseWorkout()
}
.buttonStyle(PauseButtonStyle())
Button("End Workout") {
workoutManager.endWorkout()
}
.buttonStyle(EndButtonStyle())
}
case .paused:
HStack(spacing: 20) {
Button("Resume") {
workoutManager.resumeWorkout()
}
.buttonStyle(ResumeButtonStyle())
Button("End Workout") {
workoutManager.endWorkout()
}
.buttonStyle(EndButtonStyle())
}
case .finished:
VStack(spacing: 15) {
VStack {
Image(systemName: "checkmark.circle.fill")
.font(.title)
.foregroundColor(.green)
Text("Workout Complete!")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.green)
Text("Your workout has been saved to HealthKit")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Start New Workout") {
workoutManager.currentState = .idle
workoutManager.metrics.reset()
}
.buttonStyle(StartButtonStyle())
}
}
}
Spacer()
}
.padding()
.navigationBarHidden(true)
}
}
// 6.
private func formatTime(_ timeInterval: TimeInterval) -> String {
let hours = Int(timeInterval) / 3600
let minutes = (Int(timeInterval) % 3600) / 60
let seconds = Int(timeInterval) % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%d:%02d", minutes, seconds)
}
}
}WorkoutView.swift
- Use
@StateObjectto create and manage the workout manager instance, which automatically updates the UI whenever published properties change. - Display the app title at the top of the screen.
- Create a vertical stack of metric cards showing duration, heart rate, calories, and distance in real-time.
- Conditionally display heart rate data if available, or show a message prompting users to connect an external heart rate monitor when a workout is active but no sensor is detected.
- Use a switch statement on the current workout state to display appropriate controls: a start button when idle, a progress indicator when preparing, pause/end buttons when active, resume/end buttons when paused, and a completion message with a reset button when finished.
- Create a helper function to format elapsed time in HH:MM:SS or MM:SS format depending on duration.
Step 9 - Creating Reusable Components
To keep your UI code clean and maintainable, create a reusable component for displaying workout metrics.
Add this new view to your project:
import SwiftUI
// A card that displays a workout metric with title, value, and unit
struct MetricCard: View {
let title: String
let value: String
let unit: String
let color: Color
var body: some View {
VStack(spacing: 8) {
// 1.
Text(title)
.font(.headline)
.foregroundColor(.secondary)
// 2.
HStack(alignment: .lastTextBaseline, spacing: 4) {
Text(value)
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundColor(color)
// 3.
if !unit.isEmpty {
Text(unit)
.font(.title3)
.foregroundColor(.secondary)
}
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(uiColor: .systemGray6))
.cornerRadius(12)
}
}
// 4.
struct StartButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.green)
.cornerRadius(12)
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
// 5.
struct PauseButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.orange)
.cornerRadius(12)
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
// 6.
struct ResumeButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(12)
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
// 7.
struct EndButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.red)
.cornerRadius(12)
.scaleEffect(configuration.isPressed ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
}
}
MetricCard.swift
- Display the metric title (Duration, Heart Rate, Calories, Distance) in a smaller secondary font.
- Show the metric value in a large, bold, rounded font with a color that matches the metric type.
- Display the unit of measurement (BPM, kcal, km) in a smaller font next to the value, but only if a unit string was provided.
- Create a green start button style that spans the full width and slightly scales down when pressed for tactile feedback.
- Create an orange pause button style with similar press feedback.
- Create a blue resume button style to indicate continuing a paused workout.
- Create a red end button style to clearly communicate the destructive action of ending the workout.
Final Result
Congratulations! You've successfully built a complete workout tracking app for iOS using HealthKit. Your app can now start workout sessions, collect real-time health metrics (including heart rate, calories burned, and distance traveled) and save complete workouts to HealthKit's database.
Throughout this tutorial, you explored the core HealthKit workout APIs:
HKWorkoutConfigurationfor defining workout parametersHKWorkoutSessionfor managing the workout lifecycleHKLiveWorkoutBuilderfor collecting and saving dataHKLiveWorkoutDataSourcefor automatic sensor management
You also built a responsive SwiftUI interface that adapts to different workout states and handles the absence of external heart rate monitors gracefully.
You can download the project to review the full implementation or use it as a reference for your own fitness apps.