How to Build a Stretching App in SwiftUI

A stretching app lets users build timed stretch routines and follow along with a guided countdown. It's well-suited for solo iOS developers targeting the fitness and wellness niche on the App Store.

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

Prerequisites

Architecture overview

The app uses SwiftData as its local persistence layer with three models: Stretch (a single exercise), RoutineItem (an ordered link between a routine and a stretch), and StretchRoutine (a named collection). Views are driven by @Query. The active session view owns a Combine Timer.publish pipeline for the countdown, and withAnimation transitions between stretches.

StretchingApp/
├── App/
│   ├── StretchingApp.swift        # ModelContainer setup
│   └── ContentView.swift
├── Models/
│   ├── Stretch.swift
│   ├── RoutineItem.swift
│   └── StretchRoutine.swift
├── Views/
│   ├── RoutineListView.swift      # @Query list + navigation
│   ├── RoutineBuilderView.swift   # New routine sheet
│   └── ActiveSessionView.swift   # Timer + animation
└── PrivacyInfo.xcprivacy

Step-by-step

1. Data model

Define three SwiftData models so routines survive app restarts with no manual persistence code required.

import SwiftData

@Model final class Stretch {
    var name: String
    var bodyPart: String
    var durationSeconds: Int
    var instructions: String

    init(name: String, bodyPart: String, durationSeconds: Int, instructions: String) {
        self.name = name; self.bodyPart = bodyPart
        self.durationSeconds = durationSeconds; self.instructions = instructions
    }
}

@Model final class StretchRoutine {
    var name: String
    var createdAt: Date = Date.now
    @Relationship(deleteRule: .cascade) var items: [RoutineItem] = []
    var totalDuration: Int { items.compactMap(\.stretch?.durationSeconds).reduce(0, +) }
    init(name: String) { self.name = name }
}

@Model final class RoutineItem {
    var orderIndex: Int
    var stretch: Stretch?
    init(orderIndex: Int, stretch: Stretch) {
        self.orderIndex = orderIndex; self.stretch = stretch
    }
}

2. Core UI — active session with timer and animation

Use Timer.publish with a trimmed Circle to render a live countdown ring that visually shrinks each second.

struct ActiveSessionView: View {
    let routine: StretchRoutine
    @State private var index = 0
    @State private var secondsLeft = 0
    @State private var isActive = false
    private let ticker = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

    private var sorted: [RoutineItem] { routine.items.sorted { $0.orderIndex < $1.orderIndex } }
    private var current: Stretch? { sorted.indices.contains(index) ? sorted[index].stretch : nil }

    var body: some View {
        VStack(spacing: 28) {
            Text(current?.name ?? "All done!").font(.title.bold())
                .transition(.slide).id(index)
            ZStack {
                Circle().stroke(.secondary.opacity(0.2), lineWidth: 10)
                Circle()
                    .trim(from: 0, to: current.map { 1 - Double(secondsLeft) / Double(max(1, $0.durationSeconds)) } ?? 1)
                    .stroke(Color.green, style: StrokeStyle(lineWidth: 10, lineCap: .round))
                    .rotationEffect(.degrees(-90))
                    .animation(.linear(duration: 1), value: secondsLeft)
                Text("\(secondsLeft)s").font(.system(size: 48, weight: .bold, design: .rounded))
            }.frame(width: 180, height: 180)
            Button(isActive ? "Pause" : "Start") { isActive.toggle() }
                .buttonStyle(.borderedProminent).controlSize(.large).disabled(current == nil)
        }
        .onReceive(ticker) { _ in
            guard isActive, secondsLeft > 0 else { return }
            secondsLeft -= 1
            if secondsLeft == 0 { withAnimation { index += 1 }; secondsLeft = current?.durationSeconds ?? 0 }
        }
        .onAppear { secondsLeft = current?.durationSeconds ?? 0 }
        .navigationTitle("In Progress").navigationBarTitleDisplayMode(.inline)
    }
}

3. Routine builder

Present a sheet where users name a routine, tap to select stretches from the library, and drag to reorder before saving to SwiftData.

struct RoutineBuilderView: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss
    @Query private var library: [Stretch]
    @State private var routineName = ""
    @State private var picks: [Stretch] = []

    var body: some View {
        NavigationStack {
            Form {
                Section("Name") { TextField("Morning wake-up", text: $routineName) }
                Section("Library") {
                    ForEach(library) { stretch in
                        HStack {
                            VStack(alignment: .leading, spacing: 2) {
                                Text(stretch.name).font(.subheadline)
                                Text("\(stretch.durationSeconds)s · \(stretch.bodyPart)")
                                    .font(.caption).foregroundStyle(.secondary)
                            }
                            Spacer()
                            if picks.contains(where: { $0.id == stretch.id }) {
                                Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
                            }
                        }
                        .contentShape(Rectangle())
                        .onTapGesture { toggle(stretch) }
                    }
                }
                Section("Selected (\(picks.count))") {
                    ForEach(picks) { Text($0.name) }.onMove { picks.move(fromOffsets: $0, toOffset: $1) }
                }
            }
            .navigationTitle("New Routine")
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save", action: save).disabled(routineName.isEmpty || picks.isEmpty)
                }
                ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
            }
        }
    }

    private func toggle(_ s: Stretch) {
        if let i = picks.firstIndex(where: { $0.id == s.id }) { picks.remove(at: i) } else { picks.append(s) }
    }
    private func save() {
        let routine = StretchRoutine(name: routineName)
        context.insert(routine)
        picks.enumerated().forEach { i, s in routine.items.append(RoutineItem(orderIndex: i, stretch: s)) }
        dismiss()
    }
}

4. Privacy Manifest

Add PrivacyInfo.xcprivacy to your app target — App Store review will reject the build without it if you access UserDefaults or other required-reason APIs.

<?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>

Common pitfalls

Adding monetization: Subscription

Use StoreKit 2 to gate unlimited routine creation behind a monthly or annual subscription, with a free tier capped at one saved routine. Configure a subscription group in App Store Connect, then call Product.products(for:) at app launch to fetch live pricing. Check active entitlement with Transaction.currentEntitlement(for:) each time the app foregrounds — StoreKit 2 handles renewal and restore automatically. For the paywall UI, SubscriptionStoreView (iOS 17+) renders Apple's native subscription sheet and reduces App Store review friction on your first submission.

Shipping this faster with Soarias

Soarias scaffolds the full SwiftData model layer, generates a working RoutineBuilderView, and writes your PrivacyInfo.xcprivacy automatically — the four steps above arrive as compiled, runnable code from a single prompt. It also sets up fastlane lanes for screenshots, App Store metadata upload, and ASC submission so you never have to open the App Store Connect web UI.

At beginner complexity, the bulk of elapsed time is usually boilerplate setup and App Store configuration rather than the actual feature code. Soarias typically reduces that overhead from a full weekend of friction to a couple of hours, leaving you free to focus on curating your stretch library and refining the paywall experience.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. The $99/year Apple Developer Program is required to distribute on TestFlight and the App Store. You can build and run on a personal device with a free Apple ID during development, but distribution requires the paid membership.

How do I submit this to the App Store?

Archive the app via Product → Archive in Xcode, then upload through the Organizer. Complete your App Store Connect listing — screenshots (required sizes: 6.9" and 6.5"), description, privacy nutrition labels, and age rating — before submitting for review. Soarias automates all of these steps through fastlane.

Can I add HealthKit to log completed stretch sessions?

Yes. Enable the HealthKit capability in your target, request HKObjectType.workoutType() authorization, and save an HKWorkout when a session ends. Note that HealthKit authorization requires a physical device — the Simulator compiles the code but silently fails all permission requests at runtime.

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