How to Build a Heart Rate Monitor in SwiftUI
Start an HKWorkoutSession paired with an HKLiveWorkoutBuilder, then stream heart rate samples through the builder's delegate to an @Observable view model. The session runs on Apple Watch; the phone receives mirrored updates via HKWorkoutSessionMirroringStartHandler.
import HealthKit
@Observable final class HeartRateMonitor {
var bpm: Double = 0
private let store = HKHealthStore()
func start() async throws {
let hrType = HKQuantityType(.heartRate)
try await store.requestAuthorization(toShare: [], read: [hrType])
// HKWorkoutSession setup — see Full Implementation below
}
}
Full implementation
The approach uses an HKWorkoutSession configured for a generic fitness activity so HealthKit keeps the heart-rate sensor running in the background. An HKLiveWorkoutBuilder is attached to the session; its delegate fires every time a new heart-rate sample arrives, which we publish to a SwiftUI view via Swift's @Observable macro. On iOS the app receives mirrored session state from the paired Apple Watch through the workoutSessionMirroringStartHandler on HKHealthStore.
// HeartRateMonitor.swift
import HealthKit
import SwiftUI
// MARK: - Observable view model
@Observable
final class HeartRateMonitor: NSObject {
// Published state
var bpm: Double = 0
var isRunning: Bool = false
var errorMessage: String?
// HealthKit
private let store = HKHealthStore()
private var session: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
// MARK: Authorization + start
func start() async {
guard HKHealthStore.isHealthDataAvailable() else {
errorMessage = "HealthKit is not available on this device."
return
}
let hrType = HKQuantityType(.heartRate)
do {
try await store.requestAuthorization(toShare: [], read: [hrType])
try await beginSession()
} catch {
errorMessage = error.localizedDescription
}
}
private func beginSession() async throws {
let config = HKWorkoutConfiguration()
config.activityType = .other
config.locationType = .unknown
let newSession = try HKWorkoutSession(
healthStore: store,
configuration: config
)
let newBuilder = newSession.associatedWorkoutBuilder()
newBuilder.dataSource = HKLiveWorkoutDataSource(
healthStore: store,
workoutConfiguration: config
)
newSession.delegate = self
newBuilder.delegate = self
self.session = newSession
self.builder = newBuilder
newSession.startActivity(with: .now)
try await newBuilder.beginCollection(at: .now)
// Mirror session to iPhone (watchOS sends; iOS receives)
store.workoutSessionMirroringStartHandler = { [weak self] mirroredSession in
Task { @MainActor [weak self] in
self?.session = mirroredSession
self?.isRunning = true
}
}
}
// MARK: Stop
func stop() {
session?.end()
builder?.endCollection(withEnd: .now) { _, _ in }
isRunning = false
}
// MARK: Heart-rate extraction
private func process(_ statistics: HKStatistics) {
guard statistics.quantityType == HKQuantityType(.heartRate),
let quantity = statistics.mostRecentQuantity() else { return }
let value = quantity.doubleValue(
for: HKUnit.count().unitDivided(by: .minute())
)
Task { @MainActor in
bpm = value
}
}
}
// MARK: - HKWorkoutSessionDelegate
extension HeartRateMonitor: HKWorkoutSessionDelegate {
func workoutSession(
_ session: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {
Task { @MainActor in
isRunning = (toState == .running)
}
}
func workoutSession(
_ session: HKWorkoutSession,
didFailWithError error: Error
) {
Task { @MainActor in
errorMessage = error.localizedDescription
}
}
}
// MARK: - HKLiveWorkoutBuilderDelegate
extension HeartRateMonitor: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else { continue }
if let stats = workoutBuilder.statistics(for: quantityType) {
process(stats)
}
}
}
}
// MARK: - SwiftUI View
struct HeartRateView: View {
@State private var monitor = HeartRateMonitor()
@State private var pulse = false
var body: some View {
VStack(spacing: 32) {
ZStack {
Circle()
.fill(.red.opacity(0.12))
.frame(width: 160, height: 160)
.scaleEffect(pulse ? 1.15 : 1.0)
.animation(
monitor.isRunning
? .easeInOut(duration: 0.5).repeatForever(autoreverses: true)
: .default,
value: pulse
)
Image(systemName: "heart.fill")
.font(.system(size: 72))
.foregroundStyle(.red)
}
VStack(spacing: 4) {
Text(monitor.bpm > 0 ? "\(Int(monitor.bpm))" : "--")
.font(.system(size: 80, weight: .bold, design: .rounded))
.contentTransition(.numericText())
.animation(.spring, value: monitor.bpm)
Text("BPM")
.font(.title3)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(
monitor.bpm > 0
? "\(Int(monitor.bpm)) beats per minute"
: "Heart rate unavailable"
)
if let error = monitor.errorMessage {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
Button {
if monitor.isRunning {
monitor.stop()
pulse = false
} else {
Task { await monitor.start() }
pulse = true
}
} label: {
Label(
monitor.isRunning ? "Stop Monitor" : "Start Monitor",
systemImage: monitor.isRunning ? "stop.circle.fill" : "play.circle.fill"
)
.font(.headline)
.padding(.horizontal, 28)
.padding(.vertical, 14)
.background(monitor.isRunning ? Color.red : Color.green)
.foregroundStyle(.white)
.clipShape(Capsule())
}
.accessibilityHint(
monitor.isRunning
? "Stops the heart rate monitoring session"
: "Starts a new heart rate monitoring session"
)
}
.padding(32)
}
}
#Preview {
HeartRateView()
}
How it works
-
Authorization:
store.requestAuthorization(toShare: [], read: [hrType])prompts the user once. We request read-only access — no workout data is written back — which uses the minimum entitlement footprint HealthKit requires. -
Session + Builder:
HKWorkoutSessionkeeps the heart-rate sensor active even when the app is backgrounded. The associatedHKLiveWorkoutBuilderbuffers samples and firesworkoutBuilder(_:didCollectDataOf:)as data arrives, giving us a real-time stream. -
Statistics extraction: Inside the delegate,
workoutBuilder.statistics(for:)returns anHKStatisticsobject from whichmostRecentQuantity()yields the latest sample — converted to beats-per-minute withHKUnit.count().unitDivided(by: .minute()). -
Main-actor publishing: All BPM updates are dispatched to the main actor via
Task { @MainActor in bpm = value }so SwiftUI can safely re-render without explicitDispatchQueue.main.asynccalls. -
Pulse animation: The red circle uses
.animation(.easeInOut.repeatForever, value: pulse)toggled by the start/stop button, providing visual feedback that the session is active without querying HealthKit for animation timing.
Variants
Chart historical heart rate over the last hour
import Charts
struct HeartRateChartView: View {
@State private var samples: [(date: Date, bpm: Double)] = []
var body: some View {
Chart(samples, id: \.date) { sample in
LineMark(
x: .value("Time", sample.date),
y: .value("BPM", sample.bpm)
)
.foregroundStyle(.red)
.interpolationMethod(.catmullRom)
}
.chartYScale(domain: 40...200)
.chartXAxis {
AxisMarks(values: .stride(by: .minute, count: 15)) {
AxisValueLabel(format: .dateTime.hour().minute())
}
}
.frame(height: 200)
.task { samples = try await fetchLastHour() }
}
private func fetchLastHour() async throws -> [(date: Date, bpm: Double)] {
let store = HKHealthStore()
let type = HKQuantityType(.heartRate)
let start = Date.now.addingTimeInterval(-3600)
let predicate = HKQuery.predicateForSamples(
withStart: start, end: .now
)
let descriptor = HKSampleQueryDescriptor(
predicates: [.quantitySample(type: type, predicate: predicate)],
sortDescriptors: [SortDescriptor(\.startDate)]
)
let results = try await descriptor.result(for: store)
return results.map { s in
(s.startDate, s.quantity.doubleValue(
for: .count().unitDivided(by: .minute())
))
}
}
}
#Preview { HeartRateChartView() }
Zone-based colour coding
Map BPM ranges to SwiftUI colours using a computed property — for example, under 60 BPM returns .blue, 60–100 returns .green, 100–140 returns .orange, and above 140 returns .red. Pass the result into the heart icon's .foregroundStyle(zoneColor) and animate the transition with .animation(.easeInOut, value: monitor.bpm) so colour shifts feel smooth rather than abrupt.
Common pitfalls
-
Missing entitlements crash silently: You must add the HealthKit capability in your target's Signing & Capabilities and add
NSHealthShareUsageDescription(andNSHealthUpdateUsageDescriptionif writing) toInfo.plist. Omitting either causes a runtime exception, not a compile error. -
Session must originate on watchOS for live BPM:
HKWorkoutSessioncan only be started from a watchOS app; the iPhone counterpart receives a mirrored session. If you test in the iOS Simulator,HKHealthStore.isHealthDataAvailable()returnsfalse— always run on a real device or paired Watch. -
Forgetting to end collection leaks the builder: Call
builder.endCollection(withEnd:)aftersession.end(); skipping this leaves the builder running in the background and inflates battery consumption. Handle it inworkoutSession(_:didChangeTo:)for the.endedstate transition for safety. -
VoiceOver reads stale BPM: Without
.accessibilityLabelor.accessibilityValue, VoiceOver reads the raw number on first focus and never updates. Use.accessibilityValue(Text("\(Int(bpm)) BPM"))combined with.accessibilityAddTraits(.updatesFrequently)so VoiceOver announces changes automatically.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a heart rate monitor in SwiftUI for iOS 17+. Use HealthKit and HKWorkoutSession with HKLiveWorkoutBuilder. Stream live BPM updates from the builder delegate to an @Observable view model. Make it accessible (VoiceOver labels, .accessibilityValue for live BPM). Add a #Preview with realistic sample data.
In Soarias's Build phase, drop this prompt into the implementation step alongside your screen mockup so Claude Code scaffolds the HealthKit entitlement, Info.plist keys, and the full session lifecycle in one pass.
Related
FAQ
Does this work on iOS 16?
The @Observable macro and the async HKSampleQueryDescriptor both require iOS 17. For iOS 16 you'd substitute ObservableObject with @Published and wrap queries in a completion-handler HKSampleQuery. Given that iOS 17 adoption exceeded 90 % in 2025, targeting iOS 17+ is the right call for new apps in 2026.
Can I read heart rate without starting a workout session?
Yes — for historical samples use HKSampleQueryDescriptor with a time-range predicate (see the chart variant above). For live BPM without an HKWorkoutSession you can use an HKAnchoredObjectQuery with updateHandler; however the sensor sampling rate is lower and the query may be suspended in the background, so a workout session is the recommended path for real-time monitoring.
What is the UIKit equivalent?
UIKit has no built-in heart-rate UI component. The HealthKit API layer (HKHealthStore, HKWorkoutSession, delegates) is identical regardless of UI framework. In UIKit you'd deliver BPM updates to a UIViewController via a delegate or Combine publisher rather than @Observable, but the HealthKit code itself is unchanged.
Last reviewed: 2026-05-11 by the Soarias team.