How to Implement HealthKit in SwiftUI
Create an HKHealthStore, call requestAuthorization(toShare:read:) with async/await, then run an HKStatisticsQuery wrapped in a withCheckedContinuation to pull today's step count into your SwiftUI view.
import HealthKit
import SwiftUI
struct StepCountView: View {
@State private var steps = 0
private let store = HKHealthStore()
var body: some View {
Text("\(steps) steps today")
.task { await fetchSteps() }
}
private func fetchSteps() async {
guard HKHealthStore.isHealthDataAvailable() else { return }
let stepType = HKQuantityType(.stepCount)
try? await store.requestAuthorization(toShare: [], read: [stepType])
let now = Date()
let start = Calendar.current.startOfDay(for: now)
let pred = HKQuery.predicateForSamples(withStart: start, end: now)
steps = await withCheckedContinuation { cont in
let q = HKStatisticsQuery(
quantityType: stepType,
quantitySamplePredicate: pred,
options: .cumulativeSum
) { _, stats, _ in
cont.resume(returning: Int(stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0))
}
store.execute(q)
}
}
}
Full implementation
The approach below encapsulates all HealthKit logic inside an @Observable class — the iOS 17 replacement for ObservableObject — keeping your views thin. Authorization is requested once on launch via the .task modifier, then three HKStatisticsQuery calls run concurrently with Swift's structured concurrency (async let). Before this compiles you must enable the HealthKit capability in your target and add NSHealthShareUsageDescription to Info.plist.
import HealthKit
import SwiftUI
// MARK: - Manager
@Observable
final class HealthKitManager {
var stepCount: Int = 0
var heartRate: Double = 0
var activeEnergy: Double = 0
var statusMessage: String = "Tap to authorize"
private let store = HKHealthStore()
private var readTypes: Set<HKQuantityType> {
[
HKQuantityType(.stepCount),
HKQuantityType(.heartRate),
HKQuantityType(.activeEnergyBurned)
]
}
func requestAuthorization() async {
guard HKHealthStore.isHealthDataAvailable() else {
statusMessage = "HealthKit not available on this device"
return
}
do {
try await store.requestAuthorization(toShare: [], read: readTypes)
statusMessage = "Authorized"
await fetchAllMetrics()
} catch {
statusMessage = "Authorization failed: \(error.localizedDescription)"
}
}
func fetchAllMetrics() async {
async let s = fetchStatistic(.stepCount,
unit: .count(),
options: .cumulativeSum,
picker: \.sumQuantity)
async let h = fetchStatistic(.heartRate,
unit: HKUnit(from: "count/min"),
options: .discreteAverage,
picker: \.averageQuantity)
async let e = fetchStatistic(.activeEnergyBurned,
unit: .kilocalorie(),
options: .cumulativeSum,
picker: \.sumQuantity)
let (steps, hr, energy) = await (s, h, e)
stepCount = Int(steps)
heartRate = hr
activeEnergy = energy
}
private func fetchStatistic(
_ id: HKQuantityTypeIdentifier,
unit: HKUnit,
options: HKStatisticsOptions,
picker: @escaping (HKStatistics) -> HKQuantity?
) async -> Double {
let type = HKQuantityType(id)
let now = Date()
let start = Calendar.current.startOfDay(for: now)
let predicate = HKQuery.predicateForSamples(withStart: start, end: now)
return await withCheckedContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: type,
quantitySamplePredicate: predicate,
options: options
) { _, statistics, _ in
let value = statistics.flatMap(picker)?.doubleValue(for: unit) ?? 0
continuation.resume(returning: value)
}
store.execute(query)
}
}
}
// MARK: - Views
struct HealthDashboardView: View {
@State private var manager = HealthKitManager()
var body: some View {
NavigationStack {
List {
Section("Today's Activity") {
MetricRow(
label: "Steps",
value: manager.stepCount.formatted(),
icon: "figure.walk",
tint: .blue
)
MetricRow(
label: "Heart Rate",
value: String(format: "%.0f bpm", manager.heartRate),
icon: "heart.fill",
tint: .red
)
MetricRow(
label: "Active Energy",
value: String(format: "%.0f kcal", manager.activeEnergy),
icon: "flame.fill",
tint: .orange
)
}
Section("Authorization") {
Text(manager.statusMessage)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle("Health")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await manager.fetchAllMetrics() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
}
}
.task {
await manager.requestAuthorization()
}
}
}
}
struct MetricRow: View {
let label: String
let value: String
let icon: String
let tint: Color
var body: some View {
HStack {
Image(systemName: icon)
.foregroundStyle(tint)
.frame(width: 28)
.accessibilityHidden(true)
Text(label)
Spacer()
Text(value)
.foregroundStyle(.secondary)
.monospacedDigit()
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(label): \(value)")
}
}
// MARK: - Preview
#Preview {
HealthDashboardView()
}
How it works
-
Capability + Info.plist gate:
HKHealthStore.isHealthDataAvailable()returnsfalseon iPad and Mac Catalyst without HealthKit entitlements and on Simulator. Always guard on this before calling anything else to avoid a runtime crash. -
requestAuthorization(toShare:read:): The async/throws overload (available since iOS 15, recommended for iOS 17+) suspends the current task while the system sheet is presented. Passing an empty set fortoSharemeans read-only — never request write access you don't need, or App Store review will flag it. -
async letconcurrency: The threefetchStatisticcalls insidefetchAllMetrics()are dispatched simultaneously. Theawait (s, h, e)tuple line waits for all three to complete before updating the@Observableproperties, which triggers a single SwiftUI render pass. -
HKStatisticsQuery+withCheckedContinuation: HealthKit queries use a callback-based API. Wrapping them inwithCheckedContinuationbridges them into Swift concurrency cleanly without Combine or a custom publisher. Thepickerclosure chooses betweensumQuantity()(steps, energy) andaverageQuantity()(heart rate). -
@Observablemanager: Annotating the class with@Observable(Swift 5.9 / iOS 17) means the view re-renders only when a property it actually reads changes. No@Publishedboilerplate required. The manager is stored as@Statein the view so it has the same lifetime as the view hierarchy.
Variants
Live updates with HKObserverQuery
Use HKObserverQuery to receive a callback whenever new samples arrive in the Health store — useful for live workout screens or background delivery via enableBackgroundDelivery.
// Add inside HealthKitManager
private var observerQuery: HKObserverQuery?
func startObservingSteps() {
let stepType = HKQuantityType(.stepCount)
observerQuery = HKObserverQuery(
sampleType: stepType,
predicate: nil
) { [weak self] _, completionHandler, error in
guard error == nil else { completionHandler(); return }
Task { await self?.fetchAllMetrics() }
completionHandler() // required — signals HealthKit delivery is handled
}
if let q = observerQuery { store.execute(q) }
// Enable background delivery (requires HealthKit background modes entitlement)
store.enableBackgroundDelivery(
for: stepType,
frequency: .immediate
) { _, _ in }
}
func stopObservingSteps() {
if let q = observerQuery { store.stop(q) }
observerQuery = nil
}
Writing workouts with HKWorkoutBuilder
To log a workout (e.g., a run), pass HKQuantityType(.activeEnergyBurned) and HKQuantityType(.distanceWalkingRunning) in the toShare set, then use HKWorkoutBuilder with beginCollection(withStart:completion:) and endCollection(withEnd:completion:). Always pair write access with a clear user-facing action — the HealthKit review team checks this closely.
Common pitfalls
- Simulator shows zero data. The iOS Simulator does not sync real sensor data. Use a physical device, or seed the Simulator's Health app manually via Features › Health Data before running queries. Your query will succeed without errors but return empty statistics.
-
Authorization silently denied on second launch. HealthKit never tells your app whether the user denied access —
requestAuthorizationcompletes without error regardless. Always handle the "authorized but zero data" case gracefully and never assume denial means unavailability. -
App Store rejection for undeclared entitlements. If you add the HealthKit capability but ship without any actual health features, or you request
toShareaccess without a UI path where the user can write data, App Review will reject the binary. Scope yourHKQuantityTypesets to exactly what your app's UI exposes, and include clearNSHealthShareUsageDescription/NSHealthUpdateUsageDescriptionstrings. -
Never call HealthKit on the main actor.
HKStatisticsQuerycallbacks fire on an arbitrary background queue. Mutations to@Observableproperties from background threads in Swift 5.10+ require@MainActorisolation or explicitMainActor.runwrapping if you're not using structured concurrency already.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement HealthKit integration in SwiftUI for iOS 17+. Use HKHealthStore, HKStatisticsQuery, and async/await authorization. Read step count, heart rate, and active energy burned for today. Wrap the @Observable manager in a NavigationStack dashboard view. Make it accessible (VoiceOver labels on each metric row). Add a #Preview with realistic sample data.
In Soarias, drop this prompt into the Build phase after your screens are scaffolded — Claude Code will wire up the HealthKit capability, generate the Info.plist keys, and produce a compilable manager class in one shot.
Related
FAQ
Does this work on iOS 16?
The @Observable macro and the async/throws overload of requestAuthorization require iOS 17. For iOS 16 support, replace @Observable with ObservableObject + @Published and wrap the authorization call in withCheckedThrowingContinuation using the completion-handler variant. Everything else — HKStatisticsQuery, HKObserverQuery, the query types used here — is available back to iOS 13.
How do I read workout routes or sleep data?
Add the relevant type to your readTypes set — e.g., HKObjectType.workoutType() for workouts or HKCategoryType(.sleepAnalysis) for sleep. Then use HKSampleQuery (for ordered samples) instead of HKStatisticsQuery, which only works with HKQuantityType. Wrap it the same way with withCheckedContinuation.
What's the UIKit equivalent?
HealthKit is framework-agnostic — the HKHealthStore, query, and authorization APIs are identical in UIKit. The only difference is bridging: in UIKit you'd typically deliver results back to the main thread with DispatchQueue.main.async and update a UILabel, whereas in SwiftUI the @Observable / .task pattern handles that for you.
Last reviewed: 2026-05-12 by the Soarias team.