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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone for testing timer behavior when the app is backgrounded
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
- Timer pauses when backgrounded.
Timer.publishstops firing when the user switches apps. For background countdowns, you'll need to record the background timestamp and calculate elapsed time on foreground return usingscenePhase. - SwiftData doesn't preserve insertion order. To-many relationships have no guaranteed order. Always store an
orderIndexonRoutineItemand sort on read — otherwise the routine sequence will appear random after a relaunch. - App Store rejection for vague metadata. Fitness is a crowded category. Descriptions like "the best stretching app" trigger a metadata rejection. Reviewers want a clear, specific description of your routine builder's unique features.
- Paywall on first launch kills conversion. Presenting a StoreKit subscription sheet before the user has experienced the app is a leading cause of low ratings and high churn. Gate the paywall behind at least one full free session.
- Missing
PrivacyInfo.xcprivacyin the target. Adding the file to the project but forgetting to include it in the app target's build phases is a silent error that surfaces only at upload time.
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.