How to Build a Step Counter App in SwiftUI
A step counter app reads daily step data from HealthKit, visualises progress toward a configurable goal, and surfaces a home-screen widget so users never have to open the app to stay motivated. It's an ideal first health app for developers new to the Apple ecosystem.
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 — HealthKit step data is not available in the Simulator
- HealthKit entitlement enabled in your app target's Signing & Capabilities tab
Architecture overview
The app uses a thin HealthKitManager observable class as its data layer, which requests permissions, queries HKStatisticsCollectionQuery for the last 7 days of step data, and enables background delivery. SwiftData caches the most recent step totals so the WidgetKit extension can read them from an App Group container without hitting HealthKit itself. A single StepDashboardView owns the navigation stack; Swift Charts renders the weekly bar chart inline.
StepCounterApp/ ├── App/ │ └── StepCounterApp.swift ├── Health/ │ └── HealthKitManager.swift ├── Models/ │ └── DailySteps.swift ← SwiftData @Model ├── Views/ │ ├── StepDashboardView.swift │ └── WeeklyChartView.swift ├── Widget/ │ └── StepWidget.swift └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a SwiftData @Model that caches each day's step count so the widget can read it from a shared App Group without re-querying HealthKit.
import SwiftData
import Foundation
@Model
final class DailySteps {
@Attribute(.unique) var date: Date
var count: Int
var goalMet: Bool
init(date: Date, count: Int, goal: Int = 10_000) {
self.date = Calendar.current.startOfDay(for: date)
self.count = count
self.goalMet = count >= goal
}
}
// In your App entry point:
// .modelContainer(for: DailySteps.self,
// inMemory: false,
// isAutosaveEnabled: true,
// isUndoEnabled: false,
// onSetup: { _ in })
2. Core UI — step dashboard
Build the main view with a circular progress ring for today's steps and a Swift Charts weekly bar chart below it.
import SwiftUI
import Charts
struct StepDashboardView: View {
@State private var hkManager = HealthKitManager()
let goal = 10_000
var progress: Double {
min(Double(hkManager.todaySteps) / Double(goal), 1.0)
}
var body: some View {
ScrollView {
VStack(spacing: 28) {
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.2), lineWidth: 18)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.green, style: StrokeStyle(lineWidth: 18, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeOut, value: progress)
VStack(spacing: 4) {
Text("\(hkManager.todaySteps)")
.font(.system(size: 44, weight: .bold, design: .rounded))
Text("of \(goal) steps")
.font(.caption).foregroundStyle(.secondary)
}
}
.frame(width: 220, height: 220)
.padding(.top, 32)
WeeklyChartView(data: hkManager.weeklySteps, goal: goal)
.frame(height: 180)
.padding(.horizontal)
}
}
.navigationTitle("Steps")
.task { await hkManager.requestAuthorization() }
}
}
3. Daily step tracking with HealthKit
Query HealthKit for today's steps and enable background delivery so counts update silently when the app is in the background.
import HealthKit
import Observation
@Observable
final class HealthKitManager {
var todaySteps: Int = 0
var weeklySteps: [(date: Date, steps: Int)] = []
private let store = HKHealthStore()
private let stepType = HKQuantityType(.stepCount)
func requestAuthorization() async {
guard HKHealthStore.isHealthDataAvailable() else { return }
try? await store.requestAuthorization(toShare: [], read: [stepType])
await fetchWeeklySteps()
enableBackgroundDelivery()
}
func fetchWeeklySteps() async {
let start = Calendar.current.date(byAdding: .day, value: -6, to: .now)!
let anchor = Calendar.current.startOfDay(for: start)
let interval = DateComponents(day: 1)
let predicate = HKQuery.predicateForSamples(withStart: anchor, end: .now)
let results = try? await withCheckedThrowingContinuation { cont in
let q = HKStatisticsCollectionQuery(
quantityType: stepType,
quantitySamplePredicate: predicate,
options: .cumulativeSum,
anchorDate: anchor,
intervalComponents: interval)
q.initialResultsHandler = { _, col, err in
if let err { cont.resume(throwing: err); return }
cont.resume(returning: col)
}
store.execute(q)
}
var entries: [(Date, Int)] = []
results?.enumerateStatistics(from: anchor, to: .now) { stat, _ in
let count = Int(stat.sumQuantity()?.doubleValue(for: .count()) ?? 0)
entries.append((stat.startDate, count))
}
weeklySteps = entries.map { (date: $0.0, steps: $0.1) }
todaySteps = entries.last?.1 ?? 0
}
private func enableBackgroundDelivery() {
store.enableBackgroundDelivery(for: stepType, frequency: .hourly) { _, _ in }
}
}
4. Privacy Manifest (PrivacyInfo.xcprivacy)
Apple requires a Privacy Manifest for any app using HealthKit or certain system APIs — missing it is a common cause of App Store rejections in 2025–26.
<?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>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeHealth</string>
<key>NSPrivacyCollectedDataTypeLinked</key><false/>
<key>NSPrivacyCollectedDataTypeTracking</key><false/>
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
</array>
</dict>
</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
- Testing on Simulator: HealthKit returns zero data in Xcode Simulator. Always test step reading on a physical device — budget this time into your weekend sprint.
- Missing HealthKit entitlement: The entitlement must be added in both the main target and the Widget Extension target independently, or the widget will silently fail to read data.
- App Group not configured: The Widget and main app must share an App Group to pass step data. Forgetting this means the widget always shows stale or zero data.
- App Store review — HealthKit justification: Your App Store description must explicitly explain why the app needs step data. Vague descriptions like "for a better experience" result in rejection under guideline 5.1.1.
- Background delivery frequency:
.immediatefrequency is rarely granted by iOS for step counts; use.hourlyto avoid silent failures and battery complaints in reviews.
Adding monetization: Ad-supported
The most pragmatic ad integration for a beginner health app is Google AdMob's banner or interstitial format, displayed on the weekly summary screen rather than the active tracking view (Apple's HIG discourages ads near health data). Add the AdMob SDK via Swift Package Manager, initialise GADMobileAds.sharedInstance().start() in your App init, then wrap a GADBannerView in a UIViewRepresentable. Keep the ad unit IDs in a .xcconfig file so they're not hard-coded — App Store review has flagged hard-coded test IDs submitted in production builds.
Shipping this faster with Soarias
Soarias scaffolds the HealthKit permission flow, App Group entitlements, and WidgetKit target in under a minute — the three setup steps that typically consume most of a first weekend. It also generates the PrivacyInfo.xcprivacy with the correct API-access reason codes pre-filled, configures fastlane match for code signing, and drives the App Store Connect submission including screenshot upload and metadata.
For a beginner-complexity app like this one, most developers report saving four to six hours compared to doing the Xcode target wiring, entitlement setup, and ASC form-filling by hand. That's roughly one full weekend reclaimed — enough to ship a second app in the same sprint.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. The free tier lets you sideload to your own device via Xcode, but you need the $99/year Apple Developer Program membership to distribute on TestFlight or submit to the App Store. HealthKit apps also require the membership to enable the entitlement in App Store Connect.
How do I submit this to the App Store?
Archive your app in Xcode (Product → Archive), validate and upload via Xcode Organizer or xcrun altool, then complete the App Store Connect listing — screenshots, privacy nutrition labels, HealthKit usage description, and your Privacy Manifest. Apple typically reviews health apps within 24–48 hours. Soarias automates all of this from the command line.
Can my step counter widget update in real time?
WidgetKit timelines refresh on Apple's schedule — typically every 15–30 minutes for most widget slots. You can call WidgetCenter.shared.reloadTimelines(ofKind:) from your app when HealthKit background delivery fires to push a fresh count sooner, but you cannot guarantee sub-minute latency on the home screen without using a Live Activity (which requires the ActivityKit framework and a persistent foreground or Always On display).
Last reviewed: 2026-05-12 by the Soarias team.