How to Build a Pedometer in SwiftUI
Wrap CMPedometer in an @Observable class and call
startUpdates(from:withHandler:) to stream live step count, distance, and
cadence into your SwiftUI view. Remember to add NSMotionUsageDescription
to Info.plist or the pedometer will silently return no data.
import CoreMotion
import SwiftUI
@Observable
final class PedometerManager {
var steps: Int = 0
var distance: Double = 0 // metres
private let pedometer = CMPedometer()
func start() {
guard CMPedometer.isStepCountingAvailable() else { return }
pedometer.startUpdates(from: .now) { [weak self] data, _ in
guard let data else { return }
DispatchQueue.main.async {
self?.steps = data.numberOfSteps.intValue
self?.distance = data.distance?.doubleValue ?? 0
}
}
}
func stop() { pedometer.stopUpdates() }
}
Full implementation
The pattern below separates motion logic into a dedicated @Observable class
so SwiftUI can react automatically when step data changes. The view presents steps,
distance in kilometres, and current cadence (steps per second), and lets the user toggle
tracking on and off. Because CMPedometer callbacks arrive on a background
queue, each property update is dispatched back to the main actor explicitly.
import CoreMotion
import SwiftUI
// MARK: – Manager
@Observable
final class PedometerManager {
var steps: Int = 0
var distanceMetres: Double = 0
var cadence: Double = 0 // steps / second
var isTracking: Bool = false
var authorizationDenied: Bool = false
private let pedometer = CMPedometer()
func toggle() {
isTracking ? stop() : start()
}
private func start() {
guard CMPedometer.isStepCountingAvailable() else { return }
// Check authorization status (iOS 17+)
if CMPedometer.authorizationStatus() == .denied {
authorizationDenied = true
return
}
isTracking = true
pedometer.startUpdates(from: .now) { [weak self] data, error in
guard let self, let data, error == nil else { return }
let s = data.numberOfSteps.intValue
let dist = data.distance?.doubleValue ?? 0
let cad = data.currentCadence?.doubleValue ?? 0
DispatchQueue.main.async {
self.steps = s
self.distanceMetres = dist
self.cadence = cad
}
}
}
private func stop() {
pedometer.stopUpdates()
isTracking = false
}
}
// MARK: – View
struct PedometerView: View {
@State private var manager = PedometerManager()
var body: some View {
NavigationStack {
VStack(spacing: 32) {
if manager.authorizationDenied {
ContentUnavailableView(
"Motion Access Denied",
systemImage: "figure.walk.slash",
description: Text("Enable Motion & Fitness in Settings.")
)
} else {
metricsGrid
toggleButton
}
}
.padding()
.navigationTitle("Pedometer")
}
}
private var metricsGrid: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 20) {
MetricCard(
value: "\(manager.steps)",
label: "Steps",
icon: "figure.walk",
color: .blue
)
MetricCard(
value: String(format: "%.2f km", manager.distanceMetres / 1_000),
label: "Distance",
icon: "map",
color: .green
)
MetricCard(
value: String(format: "%.1f spm", manager.cadence * 60),
label: "Cadence",
icon: "metronome",
color: .orange
)
MetricCard(
value: manager.isTracking ? "Active" : "Idle",
label: "Status",
icon: manager.isTracking ? "antenna.radiowaves.left.and.right" : "pause.circle",
color: manager.isTracking ? .green : .secondary
)
}
}
private var toggleButton: some View {
Button(action: manager.toggle) {
Label(
manager.isTracking ? "Stop Tracking" : "Start Tracking",
systemImage: manager.isTracking ? "stop.circle.fill" : "play.circle.fill"
)
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(manager.isTracking ? Color.red.opacity(0.15) : Color.blue.opacity(0.15))
.foregroundStyle(manager.isTracking ? .red : .blue)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.accessibilityLabel(manager.isTracking ? "Stop step tracking" : "Start step tracking")
}
}
// MARK: – Subview
struct MetricCard: View {
let value: String
let label: String
let icon: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(color)
Text(value)
.font(.title.bold())
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(label): \(value)")
}
}
// MARK: – Preview
#Preview {
PedometerView()
}
How it works
-
Availability guard —
CMPedometer.isStepCountingAvailable()returnsfalseon iPads without the M7 motion co-processor and on Simulator. Always check this before callingstartUpdatesto avoid a silent no-op. -
Authorization check —
CMPedometer.authorizationStatus()(new in iOS 17 as a class-level synchronous API) lets you surface aContentUnavailableViewimmediately instead of waiting for the first denied callback. AddNSMotionUsageDescriptioninInfo.plistor the system will never prompt the user at all. -
Live updates —
startUpdates(from: .now)begins streaming cumulative data for the current session. EachCMPedometerDataobject carriesnumberOfSteps,distance, andcurrentCadence. Cadence is steps per second, so multiply by 60 for the familiar "steps per minute" unit. -
Main-thread dispatch — The handler fires on an internal background
queue. Wrapping state mutations in
DispatchQueue.main.asynckeeps SwiftUI updates on the main actor and avoids runtime warnings in Xcode 16. -
@Observablemanager — Using the Observation framework (iOS 17+) instead ofObservableObjectmeans the view only re-renders for the specific properties it reads, keeping the UI efficient even as step data arrives several times per second.
Variants
Query historical step data for a date range
Use queryPedometerData(from:to:withHandler:) to fetch aggregated steps for
any past interval — useful for a weekly summary screen.
func fetchSteps(for date: Date) async throws -> Int {
let calendar = Calendar.current
let start = calendar.startOfDay(for: date)
let end = calendar.date(byAdding: .day, value: 1, to: start)!
return try await withCheckedThrowingContinuation { continuation in
pedometer.queryPedometerData(from: start, to: end) { data, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: data?.numberOfSteps.intValue ?? 0)
}
}
}
}
// Usage inside a Task:
// let steps = try await manager.fetchSteps(for: .now)
Floor counting
CMPedometer.isFloorCountingAvailable() checks for barometer support (iPhone
6 and later). When available, CMPedometerData.floorsAscended and
floorsDescended are populated automatically — just add two more
MetricCard views driven by those values. No additional permission is
required beyond the existing NSMotionUsageDescription.
Common pitfalls
-
Forgetting
NSMotionUsageDescription— Without this key inInfo.plist, iOS will never display the permission prompt andCMPedometersilently returns no data. This is the most common "it's not working" bug. -
Calling
startUpdatesin Simulator —isStepCountingAvailable()returnsfalseon Simulator. Add a mock data path (#if targetEnvironment(simulator)) for UI development, or use a physical device for pedometer testing. -
Not stopping updates on disappear —
CMPedometerkeeps the motion co-processor active while running, which drains battery. Callpedometer.stopUpdates()in.onDisappearor in the manager'sdeinitto release the hardware resource when the view leaves the screen. -
Cadence returning nil —
currentCadenceis only populated while the device is actively detecting walking motion. It will benilwhen the user is stationary, so always use optional chaining or a default of0.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a pedometer in SwiftUI for iOS 17+. Use CMPedometer from CoreMotion. Display live steps, distance (km), and cadence (steps/min). Handle CMPedometer.authorizationStatus() == .denied gracefully. Make it accessible (VoiceOver labels on all metric cards). Add a #Preview with realistic sample data.
In the Soarias Build phase, drop this prompt into the implementation step after your screens are scaffolded — Claude Code will wire up CoreMotion permissions and live data binding in one pass, leaving you to refine the UI polish.
Related
FAQ
Does this work on iOS 16?
The CMPedometer APIs used here are available back to iOS 8, but the
@Observable macro and the synchronous
CMPedometer.authorizationStatus() class method require iOS 17+. If you
need iOS 16 support, replace @Observable with
ObservableObject + @Published and check authorization
inside the callback instead.
Does the pedometer work when the app is in the background?
startUpdates(from:withHandler:) only delivers data while the app is in
the foreground. For background step accumulation, use
queryPedometerData(from:to:withHandler:) to fetch the aggregate when
the app next resumes — CoreMotion continues counting steps in the hardware even
when your app is suspended, so the totals will be up to date.
What is the UIKit / older equivalent?
UIKit has no pedometer-specific class — CMPedometer lives in
CoreMotion and is used identically from UIKit view controllers. The only difference
is that in UIKit you'd update UI labels on the main thread manually rather than
relying on SwiftUI's reactive rendering. The CoreMotion API surface is unchanged.
Last reviewed: 2026-05-11 by the Soarias team.