How to Build a Workout Tracker App in SwiftUI
A Workout Tracker app lets users log exercises, track sets and reps, and visualize strength progress over time — all synced with the Health app via HealthKit. This guide is for iOS developers who want a polished, shippable fitness app using SwiftData and Swift Charts.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight, App Store, and HealthKit entitlements
- Basic Swift/SwiftUI knowledge (NavigationSplitView, @State, basic data flow)
- A physical iPhone for HealthKit testing — the Simulator does not support writing HKWorkout samples reliably
- Familiarity with SwiftData basics is helpful but not required
Architecture overview
The app uses SwiftData as its on-device persistence layer with three core models: WorkoutSession, Exercise, and WorkoutSet. An @Observable WorkoutStore manages the active session state and bridges completed sessions to HealthKit. Views are thin — they query SwiftData via @Query and push mutations through the store. Swift Charts consumes aggregated query results directly without a separate view model.
WorkoutTracker/ ├── App/ │ └── WorkoutTrackerApp.swift # ModelContainer setup ├── Models/ │ ├── WorkoutSession.swift # @Model: date, duration, notes │ ├── Exercise.swift # @Model: name, category, sets │ └── WorkoutSet.swift # @Model: reps, weight, completed ├── Stores/ │ └── WorkoutStore.swift # @Observable: active session + HK bridge ├── Views/ │ ├── HomeView.swift # @Query session list │ ├── ActiveWorkoutView.swift # Live logging UI │ ├── ExercisePickerView.swift # Searchable exercise library │ ├── SetRowView.swift # Single set row with stepper │ └── ProgressChartsView.swift # Swift Charts ├── HealthKit/ │ └── HealthKitManager.swift # HKHealthStore wrapper └── PrivacyInfo.xcprivacy
Step-by-step
1. Project setup with Xcode and SwiftData
Create a new iOS App target in Xcode 16, select SwiftUI and SwiftData when prompted. Then add the HealthKit capability in Signing & Capabilities and add NSHealthShareUsageDescription and NSHealthUpdateUsageDescription to your Info.plist.
// WorkoutTrackerApp.swift
import SwiftUI
import SwiftData
@main
struct WorkoutTrackerApp: App {
let container: ModelContainer = {
let schema = Schema([
WorkoutSession.self,
Exercise.self,
WorkoutSet.self
])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}()
var body: some Scene {
WindowGroup {
HomeView()
.modelContainer(container)
}
}
}
2. Data model with @Model
Define your three SwiftData models. Keep WorkoutSet lightweight — it's the leaf node that gets logged hundreds of times. The cascade delete rule on Exercise.sets prevents orphaned rows.
// Models/WorkoutSession.swift
import Foundation
import SwiftData
@Model
final class WorkoutSession {
var date: Date
var durationSeconds: Int
var notes: String
@Relationship(deleteRule: .cascade) var exercises: [Exercise] = []
init(date: Date = .now, durationSeconds: Int = 0, notes: String = "") {
self.date = date
self.durationSeconds = durationSeconds
self.notes = notes
}
var totalVolume: Double {
exercises.flatMap(\.sets).reduce(0) { $0 + ($1.weight * Double($1.reps)) }
}
}
// Models/Exercise.swift
@Model
final class Exercise {
var name: String
var category: String // e.g. "Push", "Pull", "Legs"
@Relationship(deleteRule: .cascade) var sets: [WorkoutSet] = []
init(name: String, category: String = "Other") {
self.name = name
self.category = category
}
}
// Models/WorkoutSet.swift
@Model
final class WorkoutSet {
var reps: Int
var weight: Double // kg
var isCompleted: Bool
var timestamp: Date
init(reps: Int = 10, weight: Double = 60, isCompleted: Bool = false) {
self.reps = reps
self.weight = weight
self.isCompleted = false
self.timestamp = .now
}
}
3. Home view — session list
The home view queries SwiftData directly with @Query. Tapping "Start Workout" inserts a new WorkoutSession into the context and pushes the active workout view.
// Views/HomeView.swift
import SwiftUI
import SwiftData
struct HomeView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \WorkoutSession.date, order: .reverse)
private var sessions: [WorkoutSession]
@State private var activeSession: WorkoutSession?
@State private var navigateToActive = false
var body: some View {
NavigationStack {
List {
ForEach(sessions) { session in
VStack(alignment: .leading, spacing: 4) {
Text(session.date.formatted(date: .abbreviated, time: .shortened))
.font(.headline)
Text("\(session.exercises.count) exercises · \(Int(session.totalVolume)) kg volume")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
.onDelete { indexSet in
indexSet.forEach { modelContext.delete(sessions[$0]) }
}
}
.navigationTitle("Workouts")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Start Workout", systemImage: "plus") {
let session = WorkoutSession()
modelContext.insert(session)
activeSession = session
navigateToActive = true
}
}
ToolbarItem(placement: .navigationBarLeading) {
NavigationLink("Progress") {
ProgressChartsView()
}
}
}
.navigationDestination(isPresented: $navigateToActive) {
if let session = activeSession {
ActiveWorkoutView(session: session)
}
}
}
}
}
#Preview {
HomeView()
.modelContainer(for: [WorkoutSession.self, Exercise.self, WorkoutSet.self],
inMemory: true)
}
4. Exercise logging with set/rep counters
This is the core feature. ActiveWorkoutView shows exercises in progress, letting users add sets, adjust reps and weight with steppers, and mark sets complete. A WorkoutStore observable tracks elapsed time and writes to HealthKit on finish.
// Stores/WorkoutStore.swift
import Foundation
import Observation
@Observable
final class WorkoutStore {
var elapsedSeconds: Int = 0
private var timer: Timer?
func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.elapsedSeconds += 1
}
}
func stopTimer() { timer?.invalidate(); timer = nil }
var formattedTime: String {
let m = elapsedSeconds / 60, s = elapsedSeconds % 60
return String(format: "%02d:%02d", m, s)
}
}
// Views/ActiveWorkoutView.swift
import SwiftUI
import SwiftData
struct ActiveWorkoutView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
let session: WorkoutSession
@State private var store = WorkoutStore()
@State private var showExercisePicker = false
var body: some View {
List {
ForEach(session.exercises) { exercise in
Section(exercise.name) {
ForEach(exercise.sets.indices, id: \.self) { i in
SetRowView(set: exercise.sets[i], setNumber: i + 1)
}
Button("+ Add Set") {
let newSet = WorkoutSet(
reps: exercise.sets.last?.reps ?? 10,
weight: exercise.sets.last?.weight ?? 60
)
exercise.sets.append(newSet)
}
.font(.subheadline)
}
}
}
.navigationTitle(store.formattedTime)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Finish") { finishWorkout() }
.bold()
.tint(.green)
}
ToolbarItem(placement: .bottomBar) {
Button("Add Exercise", systemImage: "dumbbell") {
showExercisePicker = true
}
}
}
.sheet(isPresented: $showExercisePicker) {
ExercisePickerView { name, category in
let ex = Exercise(name: name, category: category)
ex.sets.append(WorkoutSet())
session.exercises.append(ex)
}
}
.onAppear { store.startTimer() }
.onDisappear { store.stopTimer() }
}
private func finishWorkout() {
store.stopTimer()
session.durationSeconds = store.elapsedSeconds
Task { await HealthKitManager.shared.logWorkout(session: session) }
dismiss()
}
}
// Views/SetRowView.swift
struct SetRowView: View {
@Bindable var set: WorkoutSet
let setNumber: Int
var body: some View {
HStack(spacing: 12) {
Text("Set \(setNumber)")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 44, alignment: .leading)
Stepper(value: $set.reps, in: 1...100) {
Text("\(set.reps) reps").monospacedDigit()
}
Divider()
Stepper(value: $set.weight, in: 0...500, step: 2.5) {
Text(String(format: "%.1f kg", set.weight)).monospacedDigit()
}
Button {
set.isCompleted.toggle()
} label: {
Image(systemName: set.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(set.isCompleted ? .green : .secondary)
.font(.title3)
}
.buttonStyle(.plain)
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.opacity(set.isCompleted ? 0.6 : 1)
}
}
#Preview {
let set = WorkoutSet(reps: 8, weight: 80)
return SetRowView(set: set, setNumber: 1)
.modelContainer(for: WorkoutSet.self, inMemory: true)
.padding()
}
5. HealthKit integration
Request authorization once at app launch, then write a HKWorkout when the user finishes a session. Always test this on a real device — the Simulator's HealthKit store does not persist data between runs.
// HealthKit/HealthKitManager.swift
import HealthKit
import Foundation
final class HealthKitManager {
static let shared = HealthKitManager()
private let store = HKHealthStore()
private let writeTypes: Set = [
HKObjectType.workoutType()
]
func requestAuthorization() async {
guard HKHealthStore.isHealthDataAvailable() else { return }
try? await store.requestAuthorization(toShare: writeTypes, read: [])
}
func logWorkout(session: WorkoutSession) async {
guard HKHealthStore.isHealthDataAvailable() else { return }
let start = session.date
let end = start.addingTimeInterval(TimeInterval(session.durationSeconds))
let workout = HKWorkout(
activityType: .traditionalStrengthTraining,
start: start,
end: end,
duration: TimeInterval(session.durationSeconds),
totalEnergyBurned: nil,
totalDistance: nil,
metadata: [
HKMetadataKeyWorkoutBrandName: "WorkoutTracker"
]
)
try? await store.save(workout)
}
}
// Call requestAuthorization in WorkoutTrackerApp.init or .task on HomeView:
// .task { await HealthKitManager.shared.requestAuthorization() }
6. Progress charts with Swift Charts
Use Chart from the Charts framework to plot weekly volume trends and per-exercise personal records. Query SwiftData with a predicate for the last 90 days and aggregate in a computed property to keep the view fast.
// Views/ProgressChartsView.swift
import SwiftUI
import SwiftData
import Charts
struct WeeklyVolume: Identifiable {
let id = UUID()
let week: Date
let volume: Double
}
struct ProgressChartsView: View {
@Query(sort: \WorkoutSession.date) private var sessions: [WorkoutSession]
private var weeklyVolumes: [WeeklyVolume] {
let calendar = Calendar.current
let cutoff = calendar.date(byAdding: .day, value: -84, to: .now)!
let recent = sessions.filter { $0.date >= cutoff }
let grouped = Dictionary(grouping: recent) { session in
calendar.dateInterval(of: .weekOfYear, for: session.date)?.start ?? session.date
}
return grouped
.map { WeeklyVolume(week: $0.key, volume: $0.value.reduce(0) { $0 + $1.totalVolume }) }
.sorted { $0.week < $1.week }
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
Text("Weekly Volume (kg)")
.font(.headline)
.padding(.horizontal)
Chart(weeklyVolumes) { dataPoint in
BarMark(
x: .value("Week", dataPoint.week, unit: .weekOfYear),
y: .value("Volume", dataPoint.volume)
)
.foregroundStyle(.blue.gradient)
.cornerRadius(4)
}
.chartXAxis {
AxisMarks(values: .stride(by: .weekOfYear)) {
AxisValueLabel(format: .dateTime.month(.abbreviated).day())
}
}
.frame(height: 220)
.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Progress")
.navigationBarTitleDisplayMode(.large)
}
}
#Preview {
NavigationStack {
ProgressChartsView()
.modelContainer(for: WorkoutSession.self, inMemory: true)
}
}
7. Privacy Manifest (required for App Store)
As of iOS 17.4+, any app using required-reason APIs must include a PrivacyInfo.xcprivacy file. HealthKit apps must declare their data usage. Add the file by going to File → New → File from Template → App Privacy in Xcode, then fill in the fields below.
<!-- PrivacyInfo.xcprivacy (add to your app target) -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeHealth</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
Common pitfalls
- HealthKit on Simulator.
HKHealthStore.isHealthDataAvailable()returnsfalseon most simulators. Always test HealthKit writes on a physical device — guard this in your code so the app doesn't crash in simulation. - @Model classes must be final. SwiftData's
@Modelmacro requiresfinal class. Usingclasswithoutfinalcompiles but silently breaks macro expansion in some Xcode versions. - App Store review: missing HealthKit purpose strings. If you declare the HealthKit entitlement but do not include both
NSHealthUpdateUsageDescriptionandNSHealthShareUsageDescriptionin Info.plist, your app will be rejected with guideline 5.1.1. Add them even if you only write data. - SwiftData relationship orphans. If you delete a
WorkoutSessionwithoutdeleteRule: .cascadeon its relationships, childExerciseandWorkoutSetrows persist indefinitely and inflate your store size over time. - Charts performance with large datasets. Querying all historical sessions and iterating in a
Chartview causes jank as the dataset grows. Always aggregate into weekly buckets before passing data toChart, and consider limiting the query to the last 6 months with a SwiftData predicate.
Adding monetization: Subscription
Implement a monthly or annual subscription using StoreKit 2's Product.products(for:) API to fetch your subscription offerings and Transaction.currentEntitlement(for:) to check active status on launch. Gate premium features — such as unlimited exercise history, custom exercise creation, and the charts view — behind a @AppStorage("isPremium") flag that you update after verifying a valid transaction in a Transaction.updates listener. Set up your subscription product IDs in App Store Connect under your app's In-App Purchases section before testing; StoreKit 2's local StoreKit configuration file (.storekit) lets you test the full paywall flow in the Simulator without a real product ID.
Shipping this faster with Soarias
Soarias automates the scaffolding steps that eat up a full day on an intermediate project like this: it generates the Xcode project with SwiftData and HealthKit entitlements already configured, wires in a PrivacyInfo.xcprivacy with the correct required-reason codes for UserDefaults access, and sets up a fastlane Fastfile with match for code signing and deliver for ASC metadata. The StoreKit 2 subscription boilerplate — product fetch, transaction listener, entitlement check — is included as a ready-to-use module rather than something you write from scratch.
For an intermediate project like a Workout Tracker, most developers spend 2–3 days on project setup, signing, Privacy Manifest, and App Store Connect configuration before writing a single line of product code. Soarias compresses that to under an hour, leaving the full week for the actual workout logging logic, HealthKit integration, and chart polish that makes your app stand out in a competitive fitness category.
Related guides
FAQ
Does this work on iOS 16?
No. SwiftData requires iOS 17 as a minimum deployment target. If you need iOS 16 support, replace SwiftData with Core Data using the NSPersistentCloudKitContainer stack, and swap @Query for @FetchRequest. The HealthKit and Charts code is compatible with iOS 16+, but you'd lose some Charts API additions introduced in iOS 17.
Do I need a paid Apple Developer account to test?
For most of the app, no — you can run on the Simulator with a free account. But HealthKit requires a physical device, and physical device testing requires a paid Apple Developer Program membership ($99/year). TestFlight and App Store distribution also require a paid account.
How do I add this to the App Store?
Create your app record in App Store Connect, set the bundle ID to match your Xcode project, fill in privacy nutrition labels (Health & Fitness data), add at least one screenshot per required device size, and submit via Xcode's Product → Archive → Distribute flow or via fastlane's deliver action. Expect a 24–48 hour review time for a new app, longer if it's your first submission.
My SwiftData store is growing large — how do I handle data migration?
Use SwiftData's versioned schema system: define a VersionedSchema enum and a SchemaMigrationPlan for each breaking model change. For lightweight migrations (adding optional properties), SwiftData handles them automatically. For heavyweight migrations (renaming properties, changing types), implement a MigrationStage.custom with a willMigrate or didMigrate closure. Test migrations against a copy of a real user's store file before shipping.
Last reviewed: 2026-05-12 by the Soarias team.