```html SwiftUI: How to Build a Pedometer (iOS 17+, 2026)

How to Build a Pedometer in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: CMPedometer Updated: May 11, 2026
TL;DR

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

  1. Availability guardCMPedometer.isStepCountingAvailable() returns false on iPads without the M7 motion co-processor and on Simulator. Always check this before calling startUpdates to avoid a silent no-op.
  2. Authorization checkCMPedometer.authorizationStatus() (new in iOS 17 as a class-level synchronous API) lets you surface a ContentUnavailableView immediately instead of waiting for the first denied callback. Add NSMotionUsageDescription in Info.plist or the system will never prompt the user at all.
  3. Live updatesstartUpdates(from: .now) begins streaming cumulative data for the current session. Each CMPedometerData object carries numberOfSteps, distance, and currentCadence. Cadence is steps per second, so multiply by 60 for the familiar "steps per minute" unit.
  4. Main-thread dispatch — The handler fires on an internal background queue. Wrapping state mutations in DispatchQueue.main.async keeps SwiftUI updates on the main actor and avoids runtime warnings in Xcode 16.
  5. @Observable manager — Using the Observation framework (iOS 17+) instead of ObservableObject means 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

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.

```