```html SwiftUI: How to Build Heart Rate Monitor (iOS 17+, 2026)

How to Build a Heart Rate Monitor in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: HealthKit, HKWorkoutSession Updated: May 11, 2026
TL;DR

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

  1. 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.
  2. Session + Builder: HKWorkoutSession keeps the heart-rate sensor active even when the app is backgrounded. The associated HKLiveWorkoutBuilder buffers samples and fires workoutBuilder(_:didCollectDataOf:) as data arrives, giving us a real-time stream.
  3. Statistics extraction: Inside the delegate, workoutBuilder.statistics(for:) returns an HKStatistics object from which mostRecentQuantity() yields the latest sample — converted to beats-per-minute with HKUnit.count().unitDivided(by: .minute()).
  4. 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 explicit DispatchQueue.main.async calls.
  5. 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

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.

```