Build a Health and Fitness Tracker with HealthKit: Steps, Workouts, and Charts
Imagine an app that tracks your daily steps like Dash sprinting through the neighborhood, monitors your heart rate during Mr. Incredible’s boulder-lifting sessions, and logs Elastigirl’s flexibility workouts — all with live-updating charts and a mental health journal. That is exactly what you are about to build.
In this tutorial, you’ll build Incredibles Fitness Tracker — a complete SwiftUI app that reads health data from HealthKit, writes custom workout sessions themed as superhero training, displays real-time charts with Swift Charts, and logs mental health entries using the HKStateOfMind API. Along the way, you’ll learn how to request HealthKit authorization, query step counts and heart rate samples, save workout sessions, receive live data updates with anchored queries, and record state-of-mind entries.
Prerequisites
- Xcode 26+ with iOS 26 deployment target
- A physical device (HealthKit is not fully available in the Simulator — you can use the Simulator’s Health data for basic testing, but workouts and real-time updates require a device)
- Familiarity with reading health data from HealthKit
- Familiarity with HealthKit workouts
- Familiarity with Swift Charts
Contents
- Getting Started
- Step 1: Configuring HealthKit Capabilities and Permissions
- Step 2: Building the HealthKit Manager
- Step 3: Requesting Authorization
- Step 4: Reading Step Count Data
- Step 5: Reading Heart Rate Samples
- Step 6: Building the Dashboard View with Swift Charts
- Step 7: Writing Superhero Workout Sessions
- Step 8: Building the Workout Logging View
- Step 9: Real-Time Updates with HKAnchoredObjectQuery
- Step 10: Recording Mental Health with HKStateOfMind
- Step 11: Assembling the Tab-Based Navigation
- Where to Go From Here?
Getting Started
Let’s create the project and set the foundation for our Incredibles-themed fitness tracker.
- Open Xcode and create a new project using the App template.
- Set the product name to IncrediblesFitnessTracker.
- Ensure the interface is SwiftUI and the language is Swift.
- Set the deployment target to iOS 26.0.
- Choose a team for code signing (required for HealthKit entitlements).
Your project should now be ready with a basic ContentView.swift and an IncrediblesFitnessTrackerApp.swift entry
point.
Step 1: Configuring HealthKit Capabilities and Permissions
Before writing any code, you need to enable the HealthKit capability and declare the data types your app reads and writes. Without this configuration, every HealthKit API call will fail silently or throw an error.
Select your project target in Xcode, navigate to Signing & Capabilities, and click + Capability. Search for HealthKit and add it. Ensure the Clinical Health Records checkbox is unchecked — we only need standard health data.
Next, open Info.plist (or use the Info tab in your target settings) and add the following two keys:
| Key | Value |
|---|---|
NSHealthShareUsageDescription | ”Incredibles Fitness Tracker reads your steps, heart rate, and workout data.” |
NSHealthUpdateUsageDescription | ”Incredibles Fitness Tracker saves your superhero workouts and mood check-ins.” |
These strings appear in the system permission dialog when the user authorizes your app. Be specific about what data you read and write — vague descriptions lead to App Store rejections.
Note: Starting in iOS 15, HealthKit authorization uses a two-phase system. You request permission to read and write specific
HKObjectTypevalues. The user can grant or deny each type independently. Your app cannot determine whether read permission was granted — the system simply returns no data if denied.
Step 2: Building the HealthKit Manager
Every HealthKit app needs a central manager to coordinate authorization, queries, and data writes. We will create an
@Observable class that serves as the single source of truth for all health data in the app.
Create a new Swift file at Managers/HealthKitManager.swift and add the following:
import HealthKit
import Observation
@Observable
final class HealthKitManager {
// MARK: - Health Store
let healthStore = HKHealthStore()
// MARK: - Published Data
var todayStepCount: Double = 0
var weeklySteps: [DailyStepData] = []
var recentHeartRates: [HeartRateSample] = []
var isAuthorized = false
var authorizationError: String?
// MARK: - Anchors for Real-Time Updates
private var stepAnchor: HKQueryAnchor?
// MARK: - Data Types
private let stepType = HKQuantityType(.stepCount)
private let heartRateType = HKQuantityType(.heartRate)
private let workoutType = HKWorkoutType.workoutType()
private let stateOfMindType = HKSampleType.stateOfMindType()
/// All types the app needs to read from HealthKit.
var readTypes: Set<HKObjectType> {
[stepType, heartRateType, workoutType, stateOfMindType]
}
/// All types the app needs to write to HealthKit.
var writeTypes: Set<HKSampleType> {
[workoutType, stateOfMindType]
}
}
Next, add the supporting data models below the manager, in a new file at Models/HealthModels.swift:
import Foundation
struct DailyStepData: Identifiable {
let id = UUID()
let date: Date
let steps: Double
/// A Pixar hero label for the day's effort level.
var heroRating: String {
switch steps {
case 0..<3000: return "Edna Mode (Designing)"
case 3000..<7000: return "Violet (Warming Up)"
case 7000..<10000: return "Elastigirl (Stretching)"
case 10000..<15000: return "Mr. Incredible (Training)"
default: return "Dash (Full Sprint!)"
}
}
}
struct HeartRateSample: Identifiable {
let id = UUID()
let date: Date
let bpm: Double
}
These models give us identifiable structs that work seamlessly with SwiftUI List and Chart views. The heroRating
computed property maps step thresholds to Incredibles characters — a fun way to motivate users.
Step 3: Requesting Authorization
HealthKit authorization must be requested before you can read or write any data. The system presents a modal sheet where the user can toggle individual data types on or off.
Open Managers/HealthKitManager.swift and add the following method to the HealthKitManager class:
// MARK: - Authorization
func requestAuthorization() async {
guard HKHealthStore.isHealthDataAvailable() else {
authorizationError = "Health data is not available on this device."
return
}
do {
try await healthStore.requestAuthorization(
toShare: writeTypes,
read: readTypes
)
isAuthorized = true
} catch {
authorizationError = "Authorization failed: \(error.localizedDescription)"
isAuthorized = false
}
}
Warning: Never call
requestAuthorizationon every app launch. Check whether you have already requested by callingauthorizationStatus(for:)for individual write types. The system will not re-display the authorization sheet if the user has already responded — therequestAuthorizationcall will simply return immediately.
Now let’s create the authorization view. Create a new file at Views/AuthorizationView.swift:
import SwiftUI
struct AuthorizationView: View {
let manager: HealthKitManager
let onAuthorized: () -> Void
var body: some View {
VStack(spacing: 24) {
Image(systemName: "heart.circle.fill")
.font(.system(size: 80))
.foregroundStyle(.red)
Text("Incredibles Fitness Tracker")
.font(.largeTitle)
.fontWeight(.bold)
Text("Track your superhero training with real health data. "
+ "We need access to your steps, heart rate, and "
+ "workout data.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
if let error = manager.authorizationError {
Text(error)
.foregroundStyle(.red)
.font(.caption)
}
Button {
Task {
await manager.requestAuthorization()
if manager.isAuthorized {
onAuthorized()
}
}
} label: {
Label("Authorize HealthKit",
systemImage: "lock.open.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
.padding(.horizontal, 40)
}
.padding()
}
}
Checkpoint: Build and run your project on a device or Simulator. You should see the authorization view with the Incredibles Fitness Tracker title and a red “Authorize HealthKit” button. Tapping it presents the system HealthKit authorization sheet. After approving, the
isAuthorizedflag flips totrue. If you see the error message “Health data is not available,” you are running on a platform that does not support HealthKit.
Step 4: Reading Step Count Data
With authorization granted, let’s read the user’s step count. We will fetch today’s total steps using an
HKStatisticsQuery and the past seven days of
daily step data using an
HKStatisticsCollectionQuery.
Open Managers/HealthKitManager.swift and add these methods:
// MARK: - Step Count Queries
/// Fetches today's cumulative step count.
func fetchTodaySteps() async {
let now = Date()
let startOfDay = Calendar.current.startOfDay(for: now)
let predicate = HKQuery.predicateForSamples(
withStart: startOfDay,
end: now,
options: .strictStartDate
)
let query = HKStatisticsQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { [weak self] _, result, error in
guard let self, error == nil,
let sum = result?.sumQuantity() else { return }
let steps = sum.doubleValue(for: .count())
Task { @MainActor in
self.todayStepCount = steps
}
}
healthStore.execute(query)
}
This query sums all step samples from midnight until now. The .cumulativeSum option tells HealthKit to merge
overlapping samples (for example, from both your iPhone and Apple Watch).
Next, add the weekly data fetcher:
/// Fetches daily step totals for the past 7 days.
func fetchWeeklySteps() async {
let now = Date()
let calendar = Calendar.current
guard let startDate = calendar.date(
byAdding: .day, value: -6,
to: calendar.startOfDay(for: now)
) else { return }
let daily = DateComponents(day: 1)
let predicate = HKQuery.predicateForSamples(
withStart: startDate,
end: now,
options: .strictStartDate
)
let query = HKStatisticsCollectionQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: startDate,
intervalComponents: daily
)
query.initialResultsHandler = {
[weak self] _, results, error in
guard let self, error == nil,
let results else { return }
var dailyData: [DailyStepData] = []
results.enumerateStatistics(
from: startDate, to: now
) { statistics, _ in
let steps = statistics.sumQuantity()?
.doubleValue(for: .count()) ?? 0
dailyData.append(
DailyStepData(
date: statistics.startDate,
steps: steps
)
)
}
Task { @MainActor in
self.weeklySteps = dailyData
}
}
healthStore.execute(query)
}
The HKStatisticsCollectionQuery is
perfect for bucketed data — it automatically groups samples by day (or any interval you specify). We enumerate results
from our start date to now, building an array of DailyStepData values.
Checkpoint: Add temporary calls to
fetchTodaySteps()andfetchWeeklySteps()in your authorization completion handler. After building and running, check the Xcode console or add aTextdisplayingtodayStepCount. If you are using the Simulator, open the Health app and manually add step data for testing. You should see Mr. Incredible’s step count appear — if the number is 0, verify that you granted step count read permission during authorization.
Step 5: Reading Heart Rate Samples
Heart rate data adds another dimension to our tracker. Unlike steps (which are cumulative), heart rate samples are
discrete — each sample represents a single measurement at a point in time. We use
HKSampleQuery to fetch the most recent readings.
Add this method to HealthKitManager:
// MARK: - Heart Rate Queries
/// Fetches the 20 most recent heart rate samples.
func fetchRecentHeartRates() async {
let sortDescriptor = NSSortDescriptor(
key: HKSampleSortIdentifierStartDate,
ascending: false
)
let query = HKSampleQuery(
sampleType: heartRateType,
predicate: nil,
limit: 20,
sortDescriptors: [sortDescriptor]
) { [weak self] _, samples, error in
guard let self, error == nil,
let samples = samples as? [HKQuantitySample]
else { return }
let heartRates = samples.map { sample in
HeartRateSample(
date: sample.startDate,
bpm: sample.quantity.doubleValue(
for: HKUnit.count()
.unitDivided(by: .minute())
)
)
}
Task { @MainActor in
self.recentHeartRates = heartRates
}
}
healthStore.execute(query)
}
Heart rate is measured in beats per minute, which in HealthKit terms is count()/min. The unitDivided(by:) method
constructs this compound unit. We fetch the 20 most recent samples sorted by date descending so we can display them in a
chart with the most recent reading on the right.
Tip: On a real device with an Apple Watch, heart rate samples are recorded automatically throughout the day. In the Simulator, open the Health app, navigate to Heart > Heart Rate, and tap “Add Data” to create test entries.
Step 6: Building the Dashboard View with Swift Charts
Now for the visual payoff. We will build a dashboard that displays today’s steps with a hero rating, a weekly step bar chart, and a heart rate line chart — all using Swift Charts.
Create a new file at Views/DashboardView.swift:
import SwiftUI
import Charts
struct DashboardView: View {
let manager: HealthKitManager
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
todayStepsCard
weeklyStepsChart
heartRateChart
}
.padding()
}
.navigationTitle("Super Dashboard")
.task {
await manager.fetchTodaySteps()
await manager.fetchWeeklySteps()
await manager.fetchRecentHeartRates()
}
}
}
}
Now add the three subviews as computed properties in an extension. First, the today’s steps card:
// MARK: - Subviews
extension DashboardView {
private var todayStepsCard: some View {
VStack(spacing: 8) {
HStack {
Image(systemName: "figure.run")
.font(.title2)
.foregroundStyle(.red)
Text("Today's Training")
.font(.headline)
Spacer()
}
Text("\(Int(manager.todayStepCount))")
.font(.system(size: 48, weight: .bold,
design: .rounded))
.foregroundStyle(.primary)
Text("steps")
.font(.subheadline)
.foregroundStyle(.secondary)
// Hero rating based on steps
let rating = DailyStepData(
date: .now, steps: manager.todayStepCount
).heroRating
Text("Power Level: \(rating)")
.font(.callout)
.fontWeight(.medium)
.foregroundStyle(.red)
}
.padding()
.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: 16)
)
}
}
Next, add the weekly steps bar chart:
extension DashboardView {
private var weeklyStepsChart: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Weekly Training Log")
.font(.headline)
Chart(manager.weeklySteps) { day in
BarMark(
x: .value("Day", day.date, unit: .day),
y: .value("Steps", day.steps)
)
.foregroundStyle(
day.steps >= 10000
? Color.red
: Color.red.opacity(0.5)
)
.cornerRadius(4)
}
.chartYAxis {
AxisMarks(position: .leading)
}
.chartXAxis {
AxisMarks(values: .stride(by: .day)) { _ in
AxisGridLine()
AxisValueLabel(
format: .dateTime.weekday(.abbreviated)
)
}
}
.frame(height: 200)
}
.padding()
.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: 16)
)
}
}
Finally, the heart rate line chart:
extension DashboardView {
private var heartRateChart: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Heart Rate Monitor")
.font(.headline)
if manager.recentHeartRates.isEmpty {
ContentUnavailableView(
"No Heart Rate Data",
systemImage: "heart.slash",
description: Text(
"Wear your Apple Watch during a training "
+ "session to see heart rate data."
)
)
.frame(height: 200)
} else {
Chart(manager.recentHeartRates) { sample in
LineMark(
x: .value("Time", sample.date),
y: .value("BPM", sample.bpm)
)
.foregroundStyle(.red)
.interpolationMethod(.catmullRom)
PointMark(
x: .value("Time", sample.date),
y: .value("BPM", sample.bpm)
)
.foregroundStyle(.red)
.symbolSize(30)
}
.chartYScale(domain: 40...200)
.chartYAxis {
AxisMarks(position: .leading)
}
.frame(height: 200)
}
}
.padding()
.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: 16)
)
}
}
Checkpoint: Build and run your project. The dashboard should display three cards: today’s step count with a hero rating (something like “Violet (Warming Up)” for low counts or “Dash (Full Sprint!)” for high counts), a bar chart showing daily steps for the past week, and a heart rate line chart. If you see empty charts, make sure you have added sample data in the Health app on your device or Simulator. The weekly chart bars should appear in red when steps exceed 10,000 — Mr. Incredible level unlocked.
Step 7: Writing Superhero Workout Sessions
Reading data is only half the equation. Our Incredibles Fitness Tracker lets users log themed workout sessions — like “Mr. Incredible’s Strength Training” or “Dash’s Sprint Session” — and save them directly to HealthKit.
First, define the workout templates. Create Models/SuperheroWorkout.swift:
import HealthKit
enum SuperheroWorkout: String, CaseIterable, Identifiable {
case strengthTraining = "Mr. Incredible's Strength Training"
case flexibilityWorkout = "Elastigirl's Flexibility Workout"
case sprintSession = "Dash's Sprint Session"
case stealthMission = "Violet's Stealth Mission"
case frozenCardio = "Frozone's Ice Cardio"
var id: String { rawValue }
var activityType: HKWorkoutActivityType {
switch self {
case .strengthTraining:
return .traditionalStrengthTraining
case .flexibilityWorkout: return .flexibility
case .sprintSession: return .running
case .stealthMission: return .hiking
case .frozenCardio: return .cycling
}
}
var icon: String {
switch self {
case .strengthTraining:
return "figure.strengthtraining.traditional"
case .flexibilityWorkout: return "figure.flexibility"
case .sprintSession: return "figure.run"
case .stealthMission: return "figure.hiking"
case .frozenCardio: return "figure.outdoor.cycle"
}
}
var caloriesPerMinute: Double {
switch self {
case .strengthTraining: return 8.0
case .flexibilityWorkout: return 4.0
case .sprintSession: return 12.0
case .stealthMission: return 6.0
case .frozenCardio: return 10.0
}
}
}
Each case maps to a real
HKWorkoutActivityType, an SF Symbol icon,
and a calorie estimate. The mapping ensures our Pixar-themed workouts produce valid HealthKit data.
Now add the workout-saving method to HealthKitManager:
// MARK: - Writing Workouts
/// Saves a superhero workout session to HealthKit.
func saveWorkout(
type: SuperheroWorkout,
duration: TimeInterval,
startDate: Date
) async throws {
let endDate = startDate.addingTimeInterval(duration)
let totalCalories = type.caloriesPerMinute * (duration / 60.0)
let calorieQuantity = HKQuantity(
unit: .kilocalorie(),
doubleValue: totalCalories
)
let workout = HKWorkout(
activityType: type.activityType,
start: startDate,
end: endDate,
duration: duration,
totalEnergyBurned: calorieQuantity,
totalDistance: nil,
metadata: [
HKMetadataKeyWorkoutBrandName: type.rawValue
]
)
try await healthStore.save(workout)
}
We use
HKMetadataKeyWorkoutBrandName to
store the superhero workout name as metadata. This lets us identify our app’s workouts when querying later. The
totalEnergyBurned is estimated from the per-minute calorie rate multiplied by the session duration.
Apple Docs:
HKWorkout— HealthKit
Step 8: Building the Workout Logging View
Let’s build the interface where users pick a superhero workout, set a duration, and start a training session with a live timer.
Create Views/WorkoutView.swift:
import SwiftUI
struct WorkoutView: View {
let manager: HealthKitManager
@State private var selectedWorkout: SuperheroWorkout =
.strengthTraining
@State private var isTraining = false
@State private var elapsedSeconds: Int = 0
@State private var workoutStartDate: Date?
@State private var timer: Timer?
@State private var showCompletionAlert = false
@State private var completionMessage = ""
var body: some View {
NavigationStack {
VStack(spacing: 24) {
workoutPicker
timerDisplay
controlButtons
Spacer()
}
.padding()
.navigationTitle("Hero Training")
.alert(
"Training Complete!",
isPresented: $showCompletionAlert
) {
Button("OK") { }
} message: {
Text(completionMessage)
}
}
}
}
Now add the subviews:
extension WorkoutView {
private var workoutPicker: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Select Your Training Program")
.font(.headline)
ForEach(SuperheroWorkout.allCases) { workout in
Button {
if !isTraining {
selectedWorkout = workout
}
} label: {
HStack {
Image(systemName: workout.icon)
.frame(width: 30)
Text(workout.rawValue)
.font(.subheadline)
Spacer()
if selectedWorkout == workout {
Image(systemName:
"checkmark.circle.fill")
.foregroundStyle(.red)
}
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(
selectedWorkout == workout
? Color.red.opacity(0.1)
: Color.clear,
in: RoundedRectangle(cornerRadius: 10)
)
}
.buttonStyle(.plain)
.disabled(isTraining)
}
}
}
private var timerDisplay: some View {
VStack(spacing: 8) {
Text(formattedTime)
.font(.system(
size: 64, weight: .bold,
design: .monospaced
))
.foregroundStyle(isTraining ? .red : .primary)
let calories = selectedWorkout.caloriesPerMinute
* (Double(elapsedSeconds) / 60.0)
Text("\(Int(calories)) kcal burned")
.font(.title3)
.foregroundStyle(.secondary)
}
.padding(.vertical, 20)
}
private var controlButtons: some View {
HStack(spacing: 20) {
if isTraining {
Button("End Training") {
stopWorkout()
}
.buttonStyle(.borderedProminent)
.tint(.orange)
} else {
Button {
startWorkout()
} label: {
Label("Start Training",
systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
}
}
private var formattedTime: String {
let minutes = elapsedSeconds / 60
let seconds = elapsedSeconds % 60
return String(format: "%02d:%02d", minutes, seconds)
}
}
Finally, add the start and stop logic:
extension WorkoutView {
private func startWorkout() {
elapsedSeconds = 0
workoutStartDate = Date()
isTraining = true
timer = Timer.scheduledTimer(
withTimeInterval: 1, repeats: true
) { _ in
elapsedSeconds += 1
}
}
private func stopWorkout() {
timer?.invalidate()
timer = nil
isTraining = false
guard let startDate = workoutStartDate else { return }
let duration = TimeInterval(elapsedSeconds)
Task {
do {
try await manager.saveWorkout(
type: selectedWorkout,
duration: duration,
startDate: startDate
)
let calories = selectedWorkout.caloriesPerMinute
* (Double(elapsedSeconds) / 60.0)
completionMessage = """
\(selectedWorkout.rawValue) saved!
Duration: \(formattedTime)
Calories: \(Int(calories)) kcal
"""
showCompletionAlert = true
} catch {
completionMessage = "Failed to save: "
+ "\(error.localizedDescription)"
showCompletionAlert = true
}
}
elapsedSeconds = 0
workoutStartDate = nil
}
}
Checkpoint: Build and run. Navigate to the Hero Training tab. You should see five superhero workout options — Mr. Incredible’s Strength Training through Frozone’s Ice Cardio. Select “Dash’s Sprint Session,” tap Start Training, and watch the timer count up with live calorie estimates. After a few seconds, tap “End Training.” An alert should confirm the workout was saved. Open the Health app on your device and navigate to Workouts — you should see a Running workout with the brand name “Dash’s Sprint Session” in the metadata.
Step 9: Real-Time Updates with HKAnchoredObjectQuery
Static queries are fine for initial data loads, but a fitness tracker needs live updates. When the user walks another
100 steps or receives a new heart rate reading from their Apple Watch, the dashboard should update automatically.
HKAnchoredObjectQuery is designed exactly
for this — it delivers an initial batch of results plus a long-running update handler that fires whenever new samples
arrive.
Add the following to HealthKitManager:
// MARK: - Real-Time Step Updates
/// Starts an anchored query that delivers step count
/// updates in real time.
func startObservingSteps() {
let predicate = HKQuery.predicateForSamples(
withStart: Calendar.current.startOfDay(for: Date()),
end: nil,
options: .strictStartDate
)
let query = HKAnchoredObjectQuery(
type: stepType,
predicate: predicate,
anchor: stepAnchor,
limit: HKObjectQueryNoLimit
) { [weak self] _, samples, _, newAnchor, error in
guard let self, error == nil else { return }
self.stepAnchor = newAnchor
self.processStepSamples(samples)
}
// The update handler fires every time new step data
// arrives.
query.updateHandler = {
[weak self] _, samples, _, newAnchor, error in
guard let self, error == nil else { return }
self.stepAnchor = newAnchor
self.processStepSamples(samples)
}
healthStore.execute(query)
}
/// Processes incoming step samples and updates today's count.
private func processStepSamples(_ samples: [HKSample]?) {
guard let quantitySamples = samples
as? [HKQuantitySample] else { return }
let newSteps = quantitySamples.reduce(0.0) { total, sample in
total + sample.quantity.doubleValue(for: .count())
}
Task { @MainActor in
self.todayStepCount += newSteps
}
}
The anchored query stores a checkpoint (the HKQueryAnchor). On subsequent calls, it only returns samples added after
that checkpoint. This is far more efficient than re-fetching all data — think of it as Dash running a relay race and
only picking up where the last runner left off.
Note: The
updateHandlerruns on a background queue. Always dispatch UI updates to the main actor, as we do withTask { @MainActor in ... }.
Now add a method to observe heart rate updates:
// MARK: - Real-Time Heart Rate Updates
/// Starts an observer query that re-fetches heart rates
/// when new data arrives.
func startObservingHeartRate() {
let query = HKObserverQuery(
sampleType: heartRateType,
predicate: nil
) { [weak self] _, _, error in
guard let self, error == nil else { return }
Task {
await self.fetchRecentHeartRates()
}
}
healthStore.execute(query)
}
For heart rate, we use an HKObserverQuery as a
lightweight notification mechanism. When it fires, we re-fetch the latest samples. This pattern is simpler than anchored
queries when you just need to reload a fixed window of recent data.
Update the DashboardView to start observations when it appears. Open Views/DashboardView.swift and modify the
.task modifier:
.task {
await manager.fetchTodaySteps()
await manager.fetchWeeklySteps()
await manager.fetchRecentHeartRates()
manager.startObservingSteps() // New
manager.startObservingHeartRate() // New
}
Checkpoint: Build and run on a device with an Apple Watch. Walk around for a minute and return to the dashboard. The today’s step count should increase without requiring a pull-to-refresh or app relaunch. If you are testing in the Simulator, open the Health app and add a new step count entry — the dashboard should update within a few seconds. You should see Violet’s power level upgrade to Elastigirl as your steps climb.
Step 10: Recording Mental Health with HKStateOfMind
Apple introduced the HKStateOfMind API to let
apps record emotional and mental health data. Our Incredibles Fitness Tracker will include a simple mood journal where
users log how they feel after training — mapping their emotional state to Incredibles moments.
First, add the save method to HealthKitManager:
// MARK: - State of Mind
/// Saves a mental health check-in to HealthKit.
func saveStateOfMind(
valence: Double,
label: HKStateOfMind.Label,
association: HKStateOfMind.Association
) async throws {
let stateOfMind = HKStateOfMind(
date: Date(),
kind: .momentaryEmotion,
valence: valence,
labels: [label],
associations: [association]
)
try await healthStore.save(stateOfMind)
}
The valence parameter ranges from -1.0 (very unpleasant) to 1.0 (very pleasant). We use .momentaryEmotion as the
kind since we are capturing how the user feels right now, as opposed to a general daily mood.
Now create the mood logging view at Views/MoodCheckInView.swift:
import SwiftUI
import HealthKit
struct MoodCheckInView: View {
let manager: HealthKitManager
@State private var valence: Double = 0.5
@State private var selectedMood: MoodOption = .empowered
@State private var showConfirmation = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
moodHeader
valenceSlider
moodGrid
saveButton
}
.padding()
}
.navigationTitle("Hero Mood Journal")
.alert(
"Check-In Saved!",
isPresented: $showConfirmation
) {
Button("OK") { }
} message: {
Text("Your emotional state has been recorded "
+ "to Apple Health. Keep up the super work!")
}
}
}
}
Define the mood options and subviews:
enum MoodOption: String, CaseIterable, Identifiable {
case empowered = "Empowered"
case calm = "Calm"
case energized = "Energized"
case anxious = "Anxious"
case exhausted = "Exhausted"
var id: String { rawValue }
var emoji: String {
switch self {
case .empowered: return "💪"
case .calm: return "🧘"
case .energized: return "⚡"
case .anxious: return "😰"
case .exhausted: return "😮💨"
}
}
var superheroContext: String {
switch self {
case .empowered:
return "Like Mr. Incredible lifting a train"
case .calm:
return "Elastigirl's focused composure"
case .energized:
return "Dash zooming through the finish line"
case .anxious:
return "Violet before her first mission"
case .exhausted:
return "After defeating Syndrome"
}
}
var healthKitLabel: HKStateOfMind.Label {
switch self {
case .empowered: return .confident
case .calm: return .peaceful
case .energized: return .excited
case .anxious: return .worried
case .exhausted: return .drained
}
}
}
extension MoodCheckInView {
private var moodHeader: some View {
VStack(spacing: 8) {
Text("How are you feeling, Super?")
.font(.title2)
.fontWeight(.bold)
Text("After every training session, check in with "
+ "your emotions. Even superheroes need to "
+ "process their feelings.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
private var valenceSlider: some View {
VStack(spacing: 8) {
Text("Emotional Valence")
.font(.headline)
HStack {
Text("Syndrome\nWins")
.font(.caption)
.multilineTextAlignment(.center)
Slider(value: $valence, in: -1...1, step: 0.1)
.tint(.red)
Text("Saved the\nDay")
.font(.caption)
.multilineTextAlignment(.center)
}
Text("Valence: \(valence, specifier: "%.1f")")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: 12)
)
}
private var moodGrid: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Select Your Mood")
.font(.headline)
ForEach(MoodOption.allCases) { mood in
Button {
selectedMood = mood
} label: {
HStack {
Text(mood.emoji)
.font(.title2)
VStack(alignment: .leading) {
Text(mood.rawValue)
.font(.subheadline)
.fontWeight(.medium)
Text(mood.superheroContext)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectedMood == mood {
Image(systemName:
"checkmark.circle.fill")
.foregroundStyle(.red)
}
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(
selectedMood == mood
? Color.red.opacity(0.1)
: Color.clear,
in: RoundedRectangle(cornerRadius: 10)
)
}
.buttonStyle(.plain)
}
}
}
private var saveButton: some View {
Button {
Task {
try? await manager.saveStateOfMind(
valence: valence,
label: selectedMood.healthKitLabel,
association: .fitness
)
showConfirmation = true
}
} label: {
Label("Save Check-In",
systemImage: "heart.text.clipboard")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
}
Checkpoint: Build and run. Navigate to the Hero Mood Journal tab. You should see the valence slider ranging from “Syndrome Wins” (-1.0) to “Saved the Day” (1.0), along with five mood options each tied to an Incredibles character moment. Select “Energized” (Dash zooming through the finish line), slide the valence to 0.8, and tap “Save Check-In.” The confirmation alert should appear. Open the Health app and navigate to Mental Wellbeing — your state-of-mind entry should be visible there.
Step 11: Assembling the Tab-Based Navigation
With all features built, let’s wire everything together into a polished tab-based app. We will use a
TabView with three tabs: Dashboard, Hero Training, and
Mood Journal.
Open ContentView.swift and replace its contents:
import SwiftUI
struct ContentView: View {
@State private var manager = HealthKitManager()
@State private var hasAuthorized = false
var body: some View {
if hasAuthorized {
TabView {
Tab("Dashboard",
systemImage: "chart.bar.fill") {
DashboardView(manager: manager)
}
Tab("Training", systemImage: "figure.run") {
WorkoutView(manager: manager)
}
Tab("Mood",
systemImage: "heart.text.clipboard") {
MoodCheckInView(manager: manager)
}
}
.tint(.red)
} else {
AuthorizationView(manager: manager) {
hasAuthorized = true
}
}
}
}
The authorization gate ensures we only show the main app after the user has granted HealthKit permissions. The
HealthKitManager is created as @State at the ContentView level and passed down to each tab, ensuring all views
share the same data source.
Tip: Consider persisting the authorization state using
@AppStorageso returning users skip the authorization screen. You can checkhealthStore.authorizationStatus(for: stepType) != .notDeterminedon launch to determine whether the user has previously responded to the authorization prompt.
Now let’s add a finishing touch — a refresh mechanism for the dashboard. Open Views/DashboardView.swift and add a
.refreshable modifier to the ScrollView:
ScrollView {
VStack(spacing: 20) {
todayStepsCard
weeklyStepsChart
heartRateChart
}
.padding()
}
.refreshable {
await manager.fetchTodaySteps()
await manager.fetchWeeklySteps()
await manager.fetchRecentHeartRates()
}
This adds the standard pull-to-refresh gesture, giving users a manual way to reload data alongside the automatic real-time updates.
Checkpoint: Build and run the final app. You should see a tab bar at the bottom with three tabs: Dashboard (chart icon), Training (running figure icon), and Mood (clipboard heart icon). The Dashboard shows today’s step count with a hero rating, a weekly bar chart, and a heart rate chart — all updating in real time. The Training tab lets you select and complete superhero workouts. The Mood tab records emotional states to HealthKit. Tap through all three tabs and verify each feature works end to end.
Where to Go From Here?
Congratulations! You’ve built Incredibles Fitness Tracker — a full-featured health and fitness app that reads steps and heart rate from HealthKit, logs superhero-themed workout sessions, displays real-time charts, and records mental health check-ins.
Here’s what you learned:
- How to configure HealthKit capabilities, privacy descriptions, and the authorization flow
- Reading cumulative data (steps) with
HKStatisticsQueryandHKStatisticsCollectionQuery - Reading discrete samples (heart rate) with
HKSampleQuery - Writing
HKWorkoutsessions with custom metadata - Using
HKAnchoredObjectQueryfor real-time step updates andHKObserverQueryfor heart rate notifications - Recording emotional states with the
HKStateOfMindAPI - Visualizing health data with Swift Charts bar and line marks
Ideas for extending this project:
- Add an Apple Watch companion app that starts workouts directly from the wrist using
HKWorkoutSession - Implement background delivery with
enableBackgroundDelivery(for:frequency:withCompletion:)so the app can process new data even when not in the foreground - Build a weekly summary view that calculates total training time, average heart rate, and mood trends
- Add sleep tracking by querying
HKCategoryType(.sleepAnalysis)and correlating sleep quality with training intensity - Integrate Core Motion to add pedometer data and motion activity classification alongside HealthKit