How to Build a Meditation App in SwiftUI
A Meditation App guides users through timed audio sessions with synchronized breathing prompts, helping them build a consistent mindfulness practice directly on their iPhone. This guide is for iOS developers who want to ship a polished, subscription-monetized wellness app with smooth animations and reliable background audio.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge — familiarity with
@State,NavigationSplitView, and async/await - A physical iPhone for testing — AVAudioSession background audio modes cannot be fully validated in Simulator
- Audio assets (.mp3 or .m4a) for at least one meditation track; royalty-free sources like Freesound work for prototyping
- StoreKit sandbox account — create one in App Store Connect before building the paywall
Architecture overview
The app follows a lightweight MVVM approach: SwiftData persists completed sessions and user streaks, a single SessionPlayer observable manages AVAudioPlayer and the breathing-phase timer, and StoreKit 2's Product API gatekeeps premium content. Views are thin — they read state from the player and subscription manager and render accordingly. There are no network calls at runtime beyond StoreKit receipt verification, keeping the app fast and privacy-friendly.
MeditationApp/
├── App/
│ └── MeditationApp.swift # Entry point, ModelContainer setup
├── Models/
│ ├── MeditationTrack.swift # @Model: title, duration, filename, isPremium
│ ├── CompletedSession.swift # @Model: date, trackID, durationSeconds
│ └── SampleData.swift # Static seed tracks for preview/testing
├── Player/
│ ├── SessionPlayer.swift # @Observable: AVAudioPlayer + breathing timer
│ └── BreathingPhase.swift # Enum: inhale / hold / exhale / rest
├── Store/
│ └── SubscriptionManager.swift # @Observable: StoreKit 2 product + entitlement
├── Views/
│ ├── ContentView.swift # NavigationSplitView root
│ ├── TrackListView.swift # Grouped list of tracks
│ ├── PlayerView.swift # Full-screen session UI
│ ├── BreathingRingView.swift # Animated Canvas ring
│ ├── PaywallView.swift # Subscription offer sheet
│ └── ProgressView.swift # Streak + history
└── Resources/
├── Audio/ # Bundled .m4a tracks
└── PrivacyInfo.xcprivacy # Required privacy manifest
Step-by-step
1. Project setup and background audio capability
Create a new Xcode project (iOS App, SwiftUI, SwiftData storage). In Signing & Capabilities, add the Background Modes capability and check Audio, AirPlay, and Picture in Picture. Without this, audio will stop the moment the user locks their screen — which is a one-star review waiting to happen.
// MeditationApp.swift
import SwiftUI
import SwiftData
import AVFoundation
@main
struct MeditationApp: App {
init() {
configureAudioSession()
}
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [MeditationTrack.self, CompletedSession.self])
}
private func configureAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(
.playback,
mode: .default,
options: [.mixWithOthers]
)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Audio session setup failed: \(error)")
}
}
}
2. Data model with SwiftData
Define the two persisted types. MeditationTrack describes available sessions; CompletedSession records what the user has done. Keeping them separate makes streak calculations trivial without touching track metadata.
// Models/MeditationTrack.swift
import SwiftData
import Foundation
@Model
final class MeditationTrack {
var id: UUID
var title: String
var category: String // e.g. "Sleep", "Focus", "Anxiety"
var durationSeconds: Int
var audioFilename: String // bundled resource name, no extension
var isPremium: Bool
var sortOrder: Int
init(
title: String,
category: String,
durationSeconds: Int,
audioFilename: String,
isPremium: Bool = false,
sortOrder: Int = 0
) {
self.id = UUID()
self.title = title
self.category = category
self.durationSeconds = durationSeconds
self.audioFilename = audioFilename
self.isPremium = isPremium
self.sortOrder = sortOrder
}
}
// Models/CompletedSession.swift
@Model
final class CompletedSession {
var id: UUID
var trackID: UUID
var completedAt: Date
var actualDurationSeconds: Int // user may exit early
init(trackID: UUID, completedAt: Date = .now, actualDurationSeconds: Int) {
self.id = UUID()
self.trackID = trackID
self.completedAt = completedAt
self.actualDurationSeconds = actualDurationSeconds
}
}
3. Session browser UI
Group tracks by category in a NavigationSplitView. Tapping a track either opens the player (free content) or triggers the paywall sheet (premium). Keep the list view dumb — all logic lives in the player and subscription manager.
// Views/TrackListView.swift
import SwiftUI
import SwiftData
struct TrackListView: View {
@Query(sort: \MeditationTrack.sortOrder) private var tracks: [MeditationTrack]
@Environment(SubscriptionManager.self) private var store
@State private var selectedTrack: MeditationTrack?
@State private var showPaywall = false
private var grouped: [String: [MeditationTrack]] {
Dictionary(grouping: tracks, by: \.category)
}
var body: some View {
List {
ForEach(grouped.keys.sorted(), id: \.self) { category in
Section(category) {
ForEach(grouped[category] ?? []) { track in
TrackRow(track: track)
.contentShape(Rectangle())
.onTapGesture {
if track.isPremium && !store.isSubscribed {
showPaywall = true
} else {
selectedTrack = track
}
}
}
}
}
}
.navigationTitle("Meditate")
.navigationDestination(item: $selectedTrack) { track in
PlayerView(track: track)
}
.sheet(isPresented: $showPaywall) {
PaywallView()
}
}
}
struct TrackRow: View {
let track: MeditationTrack
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(track.title).font(.headline)
Text("\(track.durationSeconds / 60) min")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if track.isPremium {
Image(systemName: "lock.fill")
.foregroundStyle(.secondary)
.font(.caption)
}
}
.padding(.vertical, 4)
}
}
#Preview {
NavigationStack {
TrackListView()
}
.modelContainer(SampleData.previewContainer)
.environment(SubscriptionManager())
}
4. Core feature: timed audio sessions with breathing prompts
This is the heart of the app. SessionPlayer owns an AVAudioPlayer and a repeating Timer that ticks every second. Each tick advances the breathing phase cycle (4 s inhale → 2 s hold → 6 s exhale → 2 s rest = 14 s loop) and publishes breathingPhase and elapsed so views can react without polling.
// Player/BreathingPhase.swift
enum BreathingPhase: String {
case inhale = "Breathe In"
case hold = "Hold"
case exhale = "Breathe Out"
case rest = "Rest"
// Duration in seconds for each phase
var duration: Int {
switch self {
case .inhale: return 4
case .hold: return 2
case .exhale: return 6
case .rest: return 2
}
}
static let cycle: [BreathingPhase] = [.inhale, .hold, .exhale, .rest]
static let cycleDuration: Int = cycle.reduce(0) { $0 + $1.duration } // 14
}
// Player/SessionPlayer.swift
import AVFoundation
import Observation
import SwiftUI
@Observable
final class SessionPlayer {
private(set) var elapsed: Int = 0
private(set) var breathingPhase: BreathingPhase = .inhale
private(set) var phaseProgress: Double = 0 // 0→1 within current phase
private(set) var isPlaying = false
private(set) var isFinished = false
var track: MeditationTrack?
private var audioPlayer: AVAudioPlayer?
private var timer: Timer?
private var phaseElapsed: Int = 0
private var cyclePosition: Int = 0
func start(track: MeditationTrack) {
self.track = track
elapsed = 0
phaseElapsed = 0
cyclePosition = 0
breathingPhase = .inhale
isFinished = false
if let url = Bundle.main.url(forResource: track.audioFilename, withExtension: "m4a") {
audioPlayer = try? AVAudioPlayer(contentsOf: url)
audioPlayer?.play()
}
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.tick()
}
isPlaying = true
}
func stop() {
timer?.invalidate()
timer = nil
audioPlayer?.stop()
isPlaying = false
}
private func tick() {
guard let track else { return }
elapsed += 1
phaseElapsed += 1
// Advance breathing phase
if phaseElapsed >= breathingPhase.duration {
phaseElapsed = 0
cyclePosition = (cyclePosition + 1) % BreathingPhase.cycle.count
breathingPhase = BreathingPhase.cycle[cyclePosition]
}
phaseProgress = Double(phaseElapsed) / Double(breathingPhase.duration)
if elapsed >= track.durationSeconds {
stop()
isFinished = true
}
}
}
5. Breathing animation ring
A pulsing ring gives users a visual anchor for each breath. Animate the ring's scale with withAnimation(.easeInOut) driven by the phase transitions from SessionPlayer. Use TimelineView only if you need sub-second smoothness; a simple state-driven approach is sufficient here and easier to test.
// Views/BreathingRingView.swift
import SwiftUI
struct BreathingRingView: View {
let phase: BreathingPhase
let progress: Double
@State private var scale: CGFloat = 0.6
var body: some View {
ZStack {
Circle()
.stroke(
ringColor.opacity(0.25),
lineWidth: 18
)
.frame(width: 200, height: 200)
Circle()
.stroke(ringColor, lineWidth: 6)
.frame(width: 200, height: 200)
.scaleEffect(scale)
.animation(.easeInOut(duration: Double(phase.duration)), value: scale)
VStack(spacing: 6) {
Text(phase.rawValue)
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
Text(countdownText)
.font(.caption)
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.onChange(of: phase) { _, newPhase in
withAnimation(.easeInOut(duration: Double(newPhase.duration))) {
scale = newPhase == .inhale || newPhase == .hold ? 1.0 : 0.6
}
}
.onAppear {
withAnimation(.easeInOut(duration: Double(phase.duration))) {
scale = 1.0
}
}
}
private var ringColor: Color {
switch phase {
case .inhale: return .blue
case .hold: return .indigo
case .exhale: return .teal
case .rest: return .gray
}
}
private var countdownText: String {
let remaining = Int(ceil(Double(phase.duration) * (1 - progress)))
return "\(remaining)s"
}
}
// Views/PlayerView.swift
import SwiftUI
import SwiftData
struct PlayerView: View {
let track: MeditationTrack
@State private var player = SessionPlayer()
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
Color(.systemBackground).ignoresSafeArea()
VStack(spacing: 40) {
Text(track.title)
.font(.largeTitle.weight(.bold))
BreathingRingView(
phase: player.breathingPhase,
progress: player.phaseProgress
)
Text(timeString(player.elapsed) + " / " + timeString(track.durationSeconds))
.font(.headline.monospacedDigit())
.foregroundStyle(.secondary)
Button(player.isPlaying ? "Pause" : "Resume") {
player.isPlaying ? player.stop() : player.start(track: track)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
.onAppear { player.start(track: track) }
.onDisappear { player.stop() }
.onChange(of: player.isFinished) { _, finished in
if finished {
let session = CompletedSession(
trackID: track.id,
actualDurationSeconds: player.elapsed
)
context.insert(session)
dismiss()
}
}
.navigationBarBackButtonHidden(player.isPlaying)
}
private func timeString(_ seconds: Int) -> String {
String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
#Preview {
NavigationStack {
PlayerView(track: SampleData.freeTrack)
}
.modelContainer(SampleData.previewContainer)
}
6. StoreKit 2 subscription paywall
Use StoreKit 2's async Product.products(for:) and Transaction.currentEntitlement(for:) to gate premium tracks. Keep the paywall sheet minimal — show the value proposition, price, and a restore button. App Store review will reject a paywall that lacks a visible restore option.
// Store/SubscriptionManager.swift
import StoreKit
import Observation
@Observable
final class SubscriptionManager {
private(set) var isSubscribed = false
private(set) var monthlyProduct: Product?
private(set) var isLoading = false
private(set) var errorMessage: String?
private let productID = "com.yourapp.meditation.monthly"
func load() async {
isLoading = true
defer { isLoading = false }
do {
let products = try await Product.products(for: [productID])
monthlyProduct = products.first
await refreshEntitlement()
} catch {
errorMessage = error.localizedDescription
}
}
func purchase() async {
guard let product = monthlyProduct else { return }
do {
let result = try await product.purchase()
if case .success(let verification) = result,
case .verified = verification {
isSubscribed = true
}
} catch {
errorMessage = error.localizedDescription
}
}
func restore() async {
try? await AppStore.sync()
await refreshEntitlement()
}
private func refreshEntitlement() async {
for await result in Transaction.currentEntitlements {
if case .verified(let tx) = result, tx.productID == productID {
isSubscribed = tx.revocationDate == nil
return
}
}
isSubscribed = false
}
}
// Views/PaywallView.swift
import SwiftUI
import StoreKit
struct PaywallView: View {
@Environment(SubscriptionManager.self) private var store
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 28) {
Text("Unlock All Sessions")
.font(.largeTitle.weight(.bold))
VStack(alignment: .leading, spacing: 10) {
Label("50+ guided meditations", systemImage: "checkmark.circle.fill")
Label("Sleep & anxiety tracks", systemImage: "checkmark.circle.fill")
Label("New sessions monthly", systemImage: "checkmark.circle.fill")
}
.foregroundStyle(.primary)
if let product = store.monthlyProduct {
Button("Subscribe · \(product.displayPrice)/mo") {
Task { await store.purchase() }
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
Button("Restore Purchases") {
Task { await store.restore() }
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(32)
.task { await store.load() }
.onChange(of: store.isSubscribed) { _, subscribed in
if subscribed { dismiss() }
}
}
}
7. Privacy Manifest (required for App Store)
Since Xcode 15, apps must include a PrivacyInfo.xcprivacy file or face automatic rejection during App Store review. For a meditation app using AVAudioSession and StoreKit, declare the accessed API types and confirm no data is sold to third parties.
<!-- Resources/PrivacyInfo.xcprivacy -->
<?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>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
/* In Xcode: File → New → File from Template → "App Privacy" to add the
.xcprivacy file to your target's Resources phase. Make sure it appears
in "Copy Bundle Resources" — Xcode will NOT warn you if it's missing. */
Common pitfalls
-
Audio stops on lock screen. The single most common 1-star review for meditation apps. You must add the Background Modes capability and call
setCategory(.playback)before starting any player — forgetting either silently fails on device. -
Timer drifts over long sessions.
Timeris not high-precision. For a 30-minute session a simple repeating timer can drift by several seconds. Anchor elapsed time againstDate.now - startDateinside the tick closure instead of incrementing a counter. - Paywall missing "Restore Purchases" button causes App Store rejection. App Store guideline 3.1.1 requires a clearly visible restore option for any subscription paywall. Reviewers check this actively — missing it guarantees rejection on the first submission.
-
Bundled audio inflates binary size and triggers 200 MB OTA download warning. Keep the bundle under ~80 MB of audio for OTA installs. Host longer premium tracks via
URLSessionwith a local cache, or use On-Demand Resources (ODR) so Apple hosts them. -
Missing PrivacyInfo.xcprivacy causes automated rejection. Apple's pipeline now auto-rejects binaries that call required-reason APIs (including
UserDefaultsvia@AppStorage) without a declared reason. Add the file to the target's Copy Bundle Resources build phase — Xcode doesn't warn you if it's absent.
Adding monetization: Subscription
Implement subscriptions with StoreKit 2 — not the older StoreKit 1 SKPaymentQueue API, which Apple has deprecated for new development. Create your subscription product in App Store Connect under "Subscriptions" (not "In-App Purchases"), assign it a subscription group, and set a free trial promotional offer to improve conversion. The SubscriptionManager class above handles the full flow: loading products, purchasing, listening for Transaction.updates in a background Task, and restoring on re-install. For a meditation app, a single monthly tier at $6.99–$9.99 with a 7-day free trial is a proven conversion pattern. Offer an annual plan at roughly 40% savings to reduce monthly churn.
Shipping this faster with Soarias
Soarias automates the most tedious parts of this build: it scaffolds the full SwiftData model and AVAudioSession setup from your description, generates a correct PrivacyInfo.xcprivacy based on the APIs your project actually uses, wires up fastlane lanes for TestFlight uploads, and drafts the App Store Connect metadata (name, subtitle, keywords, description, screenshot captions) so you're not staring at a blank text field at 11 pm. The paywall sheet and StoreKit configuration are produced from your product IDs — you paste them in once, Soarias handles the boilerplate.
For an intermediate project like this — roughly a week of focused work — Soarias typically saves two to three days by eliminating the scaffolding, submission, and metadata phases. Most of the time you save shows up at the ends: the first hour of project setup and the last two days of submission prep and screenshot generation. You still own the breathing animation design and the audio experience; Soarias handles the shipping infrastructure around it.
Related guides
FAQ
Does this work on iOS 16?
The core audio and timer code is backwards-compatible, but this guide uses SwiftData (@Model), the #Preview macro, and @Observable — all of which require iOS 17. If you need iOS 16 support, replace SwiftData with Core Data, @Observable with ObservableObject/@Published, and #Preview with PreviewProvider. It's several hours of changes, which is the main reason to target iOS 17+ for new apps in 2026.
Do I need a paid Apple Developer account to test?
You can build and run on a personal device with a free Apple ID, but StoreKit sandbox purchases, TestFlight distribution, and background audio testing on a locked device all require an enrolled Apple Developer account ($99/year). For this app in particular, background audio is a core feature — you'll want a real device and a paid account early in development, not just before submission.
How do I add this to the App Store?
In App Store Connect, create a new app record, fill in the metadata (name, subtitle, up to 100 characters of keywords, description, privacy policy URL), upload at least one screenshot per device size class, set your subscription product to "Ready to Submit," then archive and upload your build from Xcode's Product → Archive menu. Submit for review. First submissions typically take 24–48 hours. See Apple's App Review Guidelines, especially section 3.1.1 for subscriptions.
How do I handle audio ducking when a phone call comes in?
Register for AVAudioSession.interruptionNotification in your SessionPlayer. When you receive a .began interruption, pause playback and pause the timer. On .ended with the shouldResume option set, resume both. Without this, a call will silence your audio but your timer keeps running, so the breathing guide falls out of sync with the audio position when the user returns — a subtle but frustrating bug.
Last reviewed: 2026-05-12 by the Soarias team.