How to Build a Yoga App in SwiftUI

A guided yoga app walks users through timed pose sequences with audio cues and progress tracking — ideal for fitness instructors publishing their own content or indie developers targeting the crowded-but-lucrative wellness category.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

SwiftData handles local persistence for sessions and poses, eliminating the need for a backend in v1. NavigationStack controls flow from the session browser into the player. Inside the player, a Timer drives the per-pose countdown and AVAudioSession keeps cue audio playing when the screen locks. StoreKit 2 gates premium session packs behind a subscription, with entitlement status re-validated on each app foreground.

YogaApp/
├── Models/
│   ├── YogaSession.swift     # @Model — title, difficulty, duration
│   └── YogaPose.swift        # @Model — name, hold time, image, cue
├── Views/
│   ├── SessionListView.swift
│   ├── SessionPlayerView.swift
│   └── PoseCardView.swift
├── Services/
│   └── AudioCuePlayer.swift  # AVAudioSession + AVPlayer wrapper
└── Resources/
    └── PrivacyInfo.xcprivacy

Step-by-step

1. Data model

Declare two SwiftData @Model types — YogaSession for the session header and YogaPose for each pose in the sequence — using a cascade delete rule so removing a session cleans up its poses automatically.

import SwiftData
import Foundation

@Model final class YogaSession {
    var id: UUID
    var title: String
    var durationSeconds: Int
    var difficulty: String
    var completedAt: Date?
    @Relationship(deleteRule: .cascade) var poses: [YogaPose]

    init(title: String, durationSeconds: Int, difficulty: String) {
        self.id = UUID()
        self.title = title
        self.durationSeconds = durationSeconds
        self.difficulty = difficulty
        self.poses = []
    }
}

@Model final class YogaPose {
    var id: UUID
    var name: String
    var holdSeconds: Int
    var imageName: String
    var cueText: String

    init(name: String, holdSeconds: Int, imageName: String, cueText: String) {
        self.id = UUID()
        self.name = name
        self.holdSeconds = holdSeconds
        self.imageName = imageName
        self.cueText = cueText
    }
}

2. Core UI — session list

Use @Query to load sessions from SwiftData and present them in a NavigationStack, opening the player in a sheet on tap so the list stays in memory for quick return.

import SwiftUI
import SwiftData

struct SessionListView: View {
    @Query(sort: \YogaSession.title) private var sessions: [YogaSession]
    @State private var selectedSession: YogaSession?

    var body: some View {
        NavigationStack {
            List(sessions) { session in
                Button { selectedSession = session } label: {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(session.title).font(.headline)
                        HStack(spacing: 12) {
                            Label("\(session.durationSeconds / 60) min",
                                  systemImage: "clock")
                            Label(session.difficulty,
                                  systemImage: "figure.yoga")
                        }
                        .font(.caption)
                        .foregroundStyle(.secondary)
                    }
                    .padding(.vertical, 4)
                }
                .buttonStyle(.plain)
            }
            .navigationTitle("Yoga Sessions")
            .sheet(item: $selectedSession) { session in
                SessionPlayerView(session: session)
            }
        }
    }
}

3. Guided sessions with poses

Wire up the countdown timer, pose auto-advance logic, and AVAudioSession configuration so the cue audio continues when the user locks their screen mid-session.

import SwiftUI
import AVFoundation

struct SessionPlayerView: View {
    let session: YogaSession
    @State private var index = 0
    @State private var timeRemaining = 0
    @State private var isRunning = false
    @State private var countdown: Timer?
    @Environment(\.dismiss) private var dismiss

    var pose: YogaPose? { index < session.poses.count ? session.poses[index] : nil }

    var body: some View {
        VStack(spacing: 24) {
            if let pose {
                Image(pose.imageName).resizable().scaledToFit()
                    .frame(maxHeight: 280)
                    .clipShape(RoundedRectangle(cornerRadius: 20))
                Text(pose.name).font(.title.bold())
                Text(pose.cueText).font(.subheadline)
                    .foregroundStyle(.secondary).multilineTextAlignment(.center)
                Text(formatted(timeRemaining))
                    .font(.system(size: 64, weight: .thin, design: .rounded))
                    .monospacedDigit()
                Button(isRunning ? "Pause" : "Start") { isRunning ? pause() : start() }
                    .buttonStyle(.borderedProminent).controlSize(.large)
            } else {
                ContentUnavailableView("Session Complete", systemImage: "checkmark.seal.fill")
                Button("Done") { dismiss() }.buttonStyle(.borderedProminent)
            }
        }
        .padding()
        .onAppear { configureAudio(); timeRemaining = pose?.holdSeconds ?? 0 }
        .onDisappear { countdown?.invalidate() }
    }

    private func start() {
        isRunning = true
        countdown = .scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            guard timeRemaining > 0 else { advance(); return }
            timeRemaining -= 1
        }
    }
    private func pause() { isRunning = false; countdown?.invalidate() }
    private func advance() {
        countdown?.invalidate(); isRunning = false
        index += 1; timeRemaining = pose?.holdSeconds ?? 0
    }
    private func configureAudio() {
        try? AVAudioSession.sharedInstance()
            .setCategory(.playback, mode: .default, options: [])
        try? AVAudioSession.sharedInstance().setActive(true)
    }
    private func formatted(_ s: Int) -> String { String(format: "%d:%02d", s/60, s%60) }
}

4. Privacy Manifest setup

Add PrivacyInfo.xcprivacy to your Xcode target (File → New → Privacy Manifest) — required since iOS 17.4 — declaring every privacy-sensitive API your app or its SDKs access.

<?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>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>C617.1</string></array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Subscription

Configure monthly and annual auto-renewable subscriptions in App Store Connect, then load them with StoreKit 2's Product.products(for:). Gate premium session packs behind a Transaction.currentEntitlement(for:) check, and re-verify on every scenePhase change to .active so cancellations and family-sharing changes are caught without a backend. A free tier of two or three starter sessions converts well in the wellness category — give users enough to feel results before the paywall appears.

Shipping this faster with Soarias

Soarias scaffolds the full SwiftData schema, AVAudioSession boilerplate, StoreKit 2 subscription flow, and the PrivacyInfo.xcprivacy from a short description of your app. It also generates fastlane lanes that capture App Store screenshots across all required device sizes and uploads your metadata — localizations, keywords, pricing — directly to App Store Connect, skipping the slow web UI entirely.

For an intermediate project like this, most developers burn two to three days on project setup, asset pipeline, and submission logistics before a single line of product code ships. Soarias compresses that to a few hours, so you can spend the week on what actually matters: your pose library, cue copy, and session structure — the content users will pay a monthly subscription for.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. The Apple Developer Program ($99/year) is required to distribute on TestFlight or the App Store and to use auto-renewable subscriptions. You can build and run on a personal device for free with a free Apple ID, but you cannot collect subscription revenue or publish publicly without a paid membership.

How do I submit this to the App Store?

Archive your app in Xcode (Product → Archive), then upload via Xcode Organizer or fastlane deliver. In App Store Connect, complete the listing — screenshots for all required device sizes, age rating questionnaire, subscription pricing tiers, and review notes explaining how the reviewer can access gated content. Apple typically reviews wellness apps within 24–48 hours.

Can I use video clips instead of static pose images?

Yes — replace the Image view with a VideoPlayer backed by AVPlayer. Bundle short clips for offline use (watch the 4 GB binary size limit) or stream from a CDN for larger libraries. If you stream, add the CDN domain to App Transport Security settings and handle the offline state gracefully so the timer still works without a connection.

Last reviewed: 2026-05-12 by the Soarias team.