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

Let’s create the project and set the foundation for our Incredibles-themed fitness tracker.

  1. Open Xcode and create a new project using the App template.
  2. Set the product name to IncrediblesFitnessTracker.
  3. Ensure the interface is SwiftUI and the language is Swift.
  4. Set the deployment target to iOS 26.0.
  5. 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:

KeyValue
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 HKObjectType values. 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 requestAuthorization on every app launch. Check whether you have already requested by calling authorizationStatus(for:) for individual write types. The system will not re-display the authorization sheet if the user has already responded — the requestAuthorization call 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 isAuthorized flag flips to true. 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() and fetchWeeklySteps() in your authorization completion handler. After building and running, check the Xcode console or add a Text displaying todayStepCount. 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 updateHandler runs on a background queue. Always dispatch UI updates to the main actor, as we do with Task { @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 @AppStorage so returning users skip the authorization screen. You can check healthStore.authorizationStatus(for: stepType) != .notDetermined on 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 HKStatisticsQuery and HKStatisticsCollectionQuery
  • Reading discrete samples (heart rate) with HKSampleQuery
  • Writing HKWorkout sessions with custom metadata
  • Using HKAnchoredObjectQuery for real-time step updates and HKObserverQuery for heart rate notifications
  • Recording emotional states with the HKStateOfMind API
  • 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