```html How to Build a Breathing Exercise App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

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

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.

```