How to Build a Breathing Exercise App in SwiftUI
A breathing exercise app guides users through timed inhale, hold, and exhale cycles for stress relief and focus. This guide is for iOS developers who want a calm, animated mindfulness tool on the App Store.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- Physical iPhone for HealthKit testing — the Simulator cannot write mindfulness data to the Health app
Architecture overview
This app is state-driven: a BreathingViewModel (@Observable) owns a Timer and cycles through four named phases, publishing circleScale and phaseLabel that the view binds to. Session history persists via SwiftData. HealthKit writes a HKCategoryTypeIdentifier.mindfulSession entry at the end of each completed session — write-only, which keeps the entitlement footprint minimal.
BreathingApp/ ├── Models/ │ ├── BreathingSession.swift # @Model: date, pattern, duration │ └── BoxBreathingPattern.swift # Struct: inhale/hold/exhale phases ├── ViewModels/ │ └── BreathingViewModel.swift # @Observable: Timer, phase state ├── Views/ │ ├── BreathingView.swift # Animated circle + start/stop │ └── HistoryView.swift # SwiftData session list └── BreathingApp.swift
Step-by-step
1. Data model
Persist session history with a SwiftData @Model and represent breathing patterns as a plain value type — clean separation from day one.
import SwiftData
import Foundation
@Model final class BreathingSession {
var date: Date
var patternName: String
var durationSeconds: Int
var cyclesCompleted: Int
init(patternName: String, durationSeconds: Int, cyclesCompleted: Int) {
self.date = .now
self.patternName = patternName
self.durationSeconds = durationSeconds
self.cyclesCompleted = cyclesCompleted
}
}
struct BoxBreathingPattern {
let name: String
let inhale: Double
let hold1: Double
let exhale: Double
let hold2: Double
static let standard = BoxBreathingPattern(
name: "Box Breathing", inhale: 4, hold1: 4, exhale: 4, hold2: 4)
static let fourSevenEight = BoxBreathingPattern(
name: "4-7-8", inhale: 4, hold1: 7, exhale: 8, hold2: 0)
}
2. Core UI
Bind the pulsing circle's size to circleScale from the view model — SwiftUI's implicit animation handles the smooth ease-in/out between phases automatically.
struct BreathingView: View {
@State private var vm = BreathingViewModel()
var body: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
VStack(spacing: 32) {
Text(vm.phaseLabel)
.font(.title2.weight(.medium))
.foregroundStyle(.secondary)
.animation(.easeInOut, value: vm.phaseLabel)
Circle()
.fill(.blue.opacity(0.15))
.frame(width: 200 * vm.circleScale,
height: 200 * vm.circleScale)
.overlay(Circle().stroke(.blue, lineWidth: 2))
.animation(.easeInOut(duration: vm.currentPhaseDuration),
value: vm.circleScale)
Text(vm.isRunning ? "\(vm.countdown)" : "–")
.font(.system(size: 52, weight: .thin, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText())
Button(vm.isRunning ? "Stop" : "Begin") {
vm.isRunning ? vm.stop() : vm.start()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
}
}
3. Box breathing pattern engine
The view model cycles through four timed phases using a Timer, publishing each phase's label, scale, and countdown so the view can animate without any logic of its own.
@Observable final class BreathingViewModel {
var circleScale: CGFloat = 0.5
var phaseLabel = "Ready"
var countdown = 0
var isRunning = false
var currentPhaseDuration: Double = 4
private var timer: Timer?
private var phaseIndex = 0
private let pattern = BoxBreathingPattern.standard
private var phases: [(label: String, dur: Double, scale: CGFloat)] {
[("Inhale", pattern.inhale, 1.0), ("Hold", pattern.hold1, 1.0),
("Exhale", pattern.exhale, 0.5), ("Hold", pattern.hold2, 0.5)]
}
func start() { isRunning = true; phaseIndex = 0; enterPhase() }
func stop() {
timer?.invalidate(); isRunning = false
phaseLabel = "Ready"; circleScale = 0.5
}
private func enterPhase() {
let p = phases[phaseIndex % phases.count]
phaseLabel = p.label; currentPhaseDuration = p.dur
circleScale = p.scale; countdown = Int(p.dur)
var tick = 0; timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self else { return }
tick += 1; self.countdown = Int(p.dur) - tick
if tick >= Int(p.dur) { self.phaseIndex += 1; self.enterPhase() }
}
}
}
4. Privacy Manifest setup
Add PrivacyInfo.xcprivacy to your app target — without it Transporter will reject your archive before it ever reaches App Store review.
<?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>NSPrivacyTracking</key><false/>
<key>NSPrivacyTrackingDomains</key><array/>
<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>NSPrivacyCollectedDataTypeHealthAndFitness</string>
<key>NSPrivacyCollectedDataTypeLinked</key><false/>
<key>NSPrivacyCollectedDataTypeTracking</key><false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
</dict>
</array>
</dict></plist>
Common pitfalls
- HealthKit silently fails on Simulator.
HKHealthStore().save(_:withCompletion:)always errors on Simulator. Add the HealthKit entitlement, include bothNSHealthUpdateUsageDescriptionandNSHealthShareUsageDescriptionin Info.plist, and test every HK write on a real device. - Timer pauses when the app is backgrounded.
Timer.scheduledTimerstops firing in the background. For sessions under two minutes this is usually acceptable; for longer sessions, snapshotDate.nowonscenePhasechange to.backgroundand recompute elapsed time when the app returns to.active. - App Store rejection for HealthKit with no stated benefit. Reviewers reject apps that declare the HealthKit entitlement without a clear user-facing description. Add one sentence to your App Store description explaining that sessions are logged to the Health app as mindfulness minutes.
- Animation snaps on phase change. Set
currentPhaseDurationbefore updatingcircleScale. If you update the scale first, SwiftUI captures the old duration and the circle jumps instead of easing. - Missing Restore Purchases button blocks non-consumable IAP review. App Store guidelines require a visible Restore Purchases button on any paywall offering non-consumable products. Apps without it are rejected at review.
Adding monetization: One-time purchase
Use StoreKit 2's Product.products(for:) to load a single non-consumable IAP (e.g. com.yourapp.pro) that unlocks additional patterns like 4-7-8 and Coherent Breathing. Gate those patterns behind an isPurchased flag you persist in UserDefaults after a successful product.purchase() call. StoreKit 2's async/await API makes the full purchase-plus-restore flow under 30 lines — no third-party SDK needed. Don't forget the Restore Purchases button; App Store review will reject apps that omit it for non-consumable IAPs.
Shipping this faster with Soarias
Soarias scaffolds the SwiftData model and @Observable view model, auto-generates PrivacyInfo.xcprivacy with the correct HealthKit and UserDefaults reasons pre-filled, configures fastlane Match for code signing, and drives the full App Store Connect submission — App ID creation, bundle ID registration, screenshot uploads, metadata, and binary delivery — from a single terminal flow.
For a beginner-complexity app like this one, the non-code overhead — provisioning profiles, signing certificates, ASC metadata, screenshot sets — reliably consumes an entire weekend for first-time shippers. With Soarias that collapses to under an hour, turning your 1–2-weekend estimate into a single focused Saturday of writing SwiftUI animations.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. The $99/year Apple Developer Program membership is required to distribute on TestFlight and submit to the App Store. You can sideload the app to your own device via Xcode for free, but you cannot share it with external testers or publish it without an enrolled account.
How do I submit this to the App Store?
Archive in Xcode (Product → Archive), then upload via Xcode Organizer or Transporter. In App Store Connect, create a new app version, fill in metadata and screenshots (at minimum iPhone 6.9" and 6.5" sizes), attach your binary, and submit for review. First-time submissions typically take 24–48 hours.
Can I ship without the HealthKit entitlement?
Absolutely. HealthKit is optional here. Remove the entitlement, drop the HKHealthStore calls, and simplify the Privacy Manifest to omit the health data type. The breathing timer works fine as a standalone app — you just won't log mindfulness sessions to the user's Health app.
Last reviewed: 2026-05-12 by the Soarias team.