Core Motion: Pedometer, Accelerometer, and Activity Recognition


Every iPhone ships with an accelerometer, a gyroscope, a magnetometer, a barometric altimeter, and a motion coprocessor that runs 24/7 counting your steps even when the phone is locked. Most apps ignore all of this. If you are building anything related to fitness, motion-controlled interactions, or context-aware features, Core Motion gives you direct access to this sensor hardware without draining the battery.

This post covers the four pillars of Core Motion: pedometer data with CMPedometer, raw sensor streams with CMMotionManager, activity classification with CMMotionActivityManager, and altitude tracking with CMAltimeter. We will not cover ARKit’s motion integration or HealthKit’s long-term storage — those are covered in their own dedicated posts.

This guide assumes you are comfortable with async/await.

Contents

The Problem

Imagine you are building a fitness companion for the monsters at Monsters, Inc. Sulley needs to track his steps on the Scare Floor, Mike wants to know if he is walking, running, or stationary during his comedy routines, and Randall needs raw accelerometer data to detect his camouflage shaking patterns. Each monster needs different sensor data at different frequencies, and the app has to do it all without turning the phone into a hand warmer.

A naive approach might look like this:

import CoreMotion

final class MonsterFitnessBroken {
    let motionManager = CMMotionManager()

    func startTracking() {
        // Starting all sensors at maximum frequency simultaneously.
        motionManager.accelerometerUpdateInterval = 0.001 // 1000 Hz -- way too fast
        motionManager.startAccelerometerUpdates()

        // Polling in a timer instead of using the handler-based API.
        Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { _ in
            if let data = self.motionManager.accelerometerData {
                print(data.acceleration.x) // No processing, just dumping raw values
            }
        }
    }
}

This code burns battery by requesting data at 1000 Hz (the accelerometer maxes out around 100 Hz on most iPhones anyway), uses polling instead of the push-based API, creates a retain cycle through the timer closure, and does nothing useful with the raw values. Let us fix all of this.

CMPedometer: Steps, Distance, and Floors

CMPedometer is the highest-level motion API. It provides pre-processed step counts, estimated distance, pace, cadence, and floors ascended/descended. The data comes from the always-on motion coprocessor (M-series chip), so querying it costs almost no battery.

Checking Availability and Permissions

Before using any Core Motion API, check hardware availability. Pedometer data requires the NSMotionUsageDescription key in your Info.plist:

import CoreMotion
import os

final class MonsterPedometer: ObservableObject {
    private let pedometer = CMPedometer()
    private let logger = Logger(subsystem: "com.monstersinc.fitness", category: "Pedometer")

    @Published var stepCount: Int = 0
    @Published var distance: Double = 0.0
    @Published var floorsAscended: Int = 0

    var isAvailable: Bool {
        CMPedometer.isStepCountingAvailable()
    }
}

Querying Historical Data

You can query step data for any time range in the past seven days. This is perfect for showing Sulley’s daily step summary:

extension MonsterPedometer {
    func fetchTodaySteps() async throws -> CMPedometerData {
        let calendar = Calendar.current
        let startOfDay = calendar.startOfDay(for: Date())

        return try await withCheckedThrowingContinuation { continuation in
            pedometer.queryPedometerData(from: startOfDay, to: Date()) { data, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                guard let data else {
                    continuation.resume(throwing: MonsterMotionError.noDataAvailable)
                    return
                }
                continuation.resume(returning: data)
            }
        }
    }

    func updateTodayStats() async {
        do {
            let data = try await fetchTodaySteps()
            await MainActor.run {
                stepCount = data.numberOfSteps.intValue
                distance = data.distance?.doubleValue ?? 0.0
                floorsAscended = data.floorsAscended?.intValue ?? 0
            }
            logger.info("Sulley walked \(self.stepCount) steps today.")
        } catch {
            logger.error("Failed to fetch pedometer data: \(error.localizedDescription)")
        }
    }
}

enum MonsterMotionError: Error, LocalizedError {
    case noDataAvailable
    case sensorUnavailable
    case authorizationDenied

    var errorDescription: String? {
        switch self {
        case .noDataAvailable:
            return "No motion data available for the requested period."
        case .sensorUnavailable:
            return "Required sensor is not available on this device."
        case .authorizationDenied:
            return "Motion data access has been denied."
        }
    }
}

Live Step Updates

For real-time tracking (a live step counter during Sulley’s workout), use the update handler:

extension MonsterPedometer {
    func startLiveTracking() {
        guard isAvailable else {
            logger.warning("Step counting is not available on this device.")
            return
        }

        pedometer.startUpdates(from: Date()) { [weak self] data, error in
            guard let self, let data else {
                if let error {
                    self?.logger.error("Live update error: \(error.localizedDescription)")
                }
                return
            }

            DispatchQueue.main.async {
                self.stepCount = data.numberOfSteps.intValue
                self.distance = data.distance?.doubleValue ?? 0.0
                self.floorsAscended = data.floorsAscended?.intValue ?? 0
            }
        }
        logger.info("Live pedometer tracking started.")
    }

    func stopLiveTracking() {
        pedometer.stopUpdates()
        logger.info("Live pedometer tracking stopped.")
    }
}

Tip: The from parameter in startUpdates(from:) determines the baseline. Pass Date() for a fresh count, or pass an earlier date to include steps taken before the tracking started.

CMMotionManager: Accelerometer, Gyroscope, and Device Motion

CMMotionManager provides raw sensor data from the accelerometer, gyroscope, and magnetometer, plus a fused “device motion” stream that combines all three with sensor fusion algorithms.

Warning: Create only one instance of CMMotionManager per app. Apple’s documentation is explicit about this. Multiple instances lead to undefined behavior and increased battery consumption.

Setting Up the Shared Manager

final class MonsterMotionTracker: ObservableObject {
    static let shared = MonsterMotionTracker()

    private let motionManager = CMMotionManager()
    private let logger = Logger(subsystem: "com.monstersinc.fitness", category: "Motion")
    private let motionQueue = OperationQueue()

    @Published var acceleration: (x: Double, y: Double, z: Double) = (0, 0, 0)
    @Published var rotationRate: (x: Double, y: Double, z: Double) = (0, 0, 0)
    @Published var isShaking: Bool = false

    private init() {
        motionQueue.name = "com.monstersinc.fitness.motion"
        motionQueue.maxConcurrentOperationCount = 1
    }
}

Accelerometer Stream

Raw accelerometer data measures acceleration forces along three axes in G-forces. At rest on a table, you will see roughly (0, 0, -1) due to gravity:

extension MonsterMotionTracker {
    /// Starts accelerometer updates for detecting Randall's camouflage shaking.
    func startAccelerometer(atFrequency hz: Double = 50.0) {
        guard motionManager.isAccelerometerAvailable else {
            logger.warning("Accelerometer not available.")
            return
        }

        motionManager.accelerometerUpdateInterval = 1.0 / hz

        motionManager.startAccelerometerUpdates(to: motionQueue) { [weak self] data, error in
            guard let self, let data else { return }

            let acc = data.acceleration
            let magnitude = sqrt(acc.x * acc.x + acc.y * acc.y + acc.z * acc.z)

            // Detect shaking: magnitude significantly above 1G (gravity).
            let shaking = magnitude > 2.5

            DispatchQueue.main.async {
                self.acceleration = (acc.x, acc.y, acc.z)
                self.isShaking = shaking
            }

            if shaking {
                self.logger.debug("Randall shake detected! Magnitude: \(magnitude)")
            }
        }
    }

    func stopAccelerometer() {
        motionManager.stopAccelerometerUpdates()
    }
}

Device Motion: The Fused Stream

For most production use cases, you want device motion rather than raw accelerometer or gyroscope data. Device motion applies sensor fusion to separate gravity from user acceleration, provides attitude (roll, pitch, yaw), and gives you a stable reference frame:

extension MonsterMotionTracker {
    /// Starts device motion updates with the best available reference frame.
    func startDeviceMotion(atFrequency hz: Double = 30.0) {
        guard motionManager.isDeviceMotionAvailable else {
            logger.warning("Device motion not available.")
            return
        }

        motionManager.deviceMotionUpdateInterval = 1.0 / hz

        let referenceFrame: CMAttitudeReferenceFrame =
            motionManager.availableAttitudeReferenceFrames.contains(.xMagneticNorthZVertical)
            ? .xMagneticNorthZVertical
            : .xArbitraryZVertical

        motionManager.startDeviceMotionUpdates(
            using: referenceFrame,
            to: motionQueue
        ) { [weak self] motion, error in
            guard let self, let motion else { return }

            let userAccel = motion.userAcceleration
            let attitude = motion.attitude

            DispatchQueue.main.async {
                self.acceleration = (userAccel.x, userAccel.y, userAccel.z)
                self.rotationRate = (attitude.roll, attitude.pitch, attitude.yaw)
            }
        }
    }

    func stopDeviceMotion() {
        motionManager.stopDeviceMotionUpdates()
    }
}

Note: userAcceleration in device motion has gravity subtracted out. If Mike is holding the phone still, userAcceleration will be near zero on all axes — unlike raw accelerometer data which always shows ~1G from gravity.

CMMotionActivityManager: What Is the User Doing?

CMMotionActivityManager uses the motion coprocessor to classify the user’s current activity: stationary, walking, running, cycling, or in a vehicle. This runs on dedicated hardware and costs effectively zero battery.

final class MonsterActivityTracker: ObservableObject {
    private let activityManager = CMMotionActivityManager()
    private let logger = Logger(
        subsystem: "com.monstersinc.fitness",
        category: "Activity"
    )

    @Published var currentActivity: String = "Unknown"
    @Published var confidence: String = "Low"

    func startActivityTracking() {
        guard CMMotionActivityManager.isActivityAvailable() else {
            logger.warning("Activity recognition not available.")
            return
        }

        activityManager.startActivityUpdates(to: .main) { [weak self] activity in
            guard let self, let activity else { return }

            let activityName = self.classifyActivity(activity)
            let confidenceLevel = self.describeConfidence(activity.confidence)

            self.currentActivity = activityName
            self.confidence = confidenceLevel
            self.logger.info("Monster is \(activityName) (confidence: \(confidenceLevel))")
        }
    }

    func stopActivityTracking() {
        activityManager.stopActivityUpdates()
    }

    private func classifyActivity(_ activity: CMMotionActivity) -> String {
        // Activities are not mutually exclusive. Check in order of specificity.
        if activity.running { return "Running" }
        if activity.cycling { return "Cycling" }
        if activity.automotive { return "Driving" }
        if activity.walking { return "Walking" }
        if activity.stationary { return "Stationary" }
        return "Unknown"
    }

    private func describeConfidence(
        _ confidence: CMMotionActivityConfidence
    ) -> String {
        switch confidence {
        case .low: return "Low"
        case .medium: return "Medium"
        case .high: return "High"
        @unknown default: return "Unknown"
        }
    }
}

Multiple activity booleans can be true simultaneously. For example, walking and stationary might both be true during a transition. The confidence property tells you how certain the classifier is — always check it in production before making decisions based on the activity state.

Querying Historical Activities

You can also query past activity data, which is useful for building a workout timeline:

extension MonsterActivityTracker {
    func fetchActivities(
        from start: Date,
        to end: Date
    ) async throws -> [CMMotionActivity] {
        return try await withCheckedThrowingContinuation { continuation in
            activityManager.queryActivityStarting(
                from: start,
                to: end,
                to: .main
            ) { activities, error in
                if let error {
                    continuation.resume(throwing: error)
                    return
                }
                continuation.resume(returning: activities ?? [])
            }
        }
    }
}

CMAltimeter: Relative Altitude Changes

CMAltimeter uses the barometric pressure sensor to measure relative altitude changes. This is how CMPedometer counts floors, but you can also get the raw pressure and relative altitude data.

final class MonsterAltimeter: ObservableObject {
    private let altimeter = CMAltimeter()
    private let logger = Logger(
        subsystem: "com.monstersinc.fitness",
        category: "Altimeter"
    )

    @Published var relativeAltitude: Double = 0.0 // meters
    @Published var pressure: Double = 0.0 // kilopascals

    func startAltitudeTracking() {
        guard CMAltimeter.isRelativeAltitudeAvailable() else {
            logger.warning("Altimeter not available.")
            return
        }

        altimeter.startRelativeAltitudeUpdates(to: .main) { [weak self] data, error in
            guard let self, let data else {
                if let error {
                    self?.logger.error("Altimeter error: \(error.localizedDescription)")
                }
                return
            }

            self.relativeAltitude = data.relativeAltitude.doubleValue
            self.pressure = data.pressure.doubleValue
        }
    }

    func stopAltitudeTracking() {
        altimeter.stopRelativeAltitudeUpdates()
    }
}

Warning: CMAltimeter provides relative altitude, not absolute. The reference point is zero at the moment you call startRelativeAltitudeUpdates. Barometric altitude is also affected by weather changes — a passing storm front can shift your readings by several meters. Do not use this for navigation-grade altitude.

Advanced Usage

Combining Sensors for Richer Context

In production, you rarely use a single sensor in isolation. Here is how to combine activity recognition with pedometer data to build a workout detector for the Scare Floor:

final class ScareFloorWorkoutDetector: ObservableObject {
    private let pedometer = CMPedometer()
    private let activityManager = CMMotionActivityManager()
    private let logger = Logger(subsystem: "com.monstersinc.fitness", category: "Workout")

    @Published var isWorkoutActive: Bool = false
    @Published var workoutSteps: Int = 0
    @Published var workoutActivity: String = "Idle"

    private var workoutStartDate: Date?

    func startDetecting() {
        // Use activity recognition to detect workout start/stop.
        activityManager.startActivityUpdates(to: .main) { [weak self] activity in
            guard let self, let activity else { return }
            guard activity.confidence == .high else { return }

            if activity.running || activity.walking {
                self.beginWorkoutIfNeeded()
                self.workoutActivity = activity.running ? "Scaring Sprint" : "Scare Walk"
            } else if activity.stationary && self.isWorkoutActive {
                self.endWorkout()
            }
        }
    }

    private func beginWorkoutIfNeeded() {
        guard !isWorkoutActive else { return }
        isWorkoutActive = true
        workoutStartDate = Date()
        workoutSteps = 0

        // Start live step counting from the workout start.
        pedometer.startUpdates(from: Date()) { [weak self] data, _ in
            guard let self, let data else { return }
            DispatchQueue.main.async {
                self.workoutSteps = data.numberOfSteps.intValue
            }
        }
        logger.info("Scare Floor workout started!")
    }

    private func endWorkout() {
        isWorkoutActive = false
        pedometer.stopUpdates()
        logger.info("Workout ended. Total steps: \(self.workoutSteps)")
    }

    func stopDetecting() {
        activityManager.stopActivityUpdates()
        pedometer.stopUpdates()
    }
}

Headphone Motion with CMHeadphoneMotionManager

If your app works with AirPods Pro or AirPods Max, CMHeadphoneMotionManager provides head-tracking data:

import CoreMotion

@available(iOS 14.0, *)
final class MonsterHeadTracker {
    private let headphoneMotion = CMHeadphoneMotionManager()

    func startTracking() {
        guard headphoneMotion.isDeviceMotionAvailable else { return }

        headphoneMotion.startDeviceMotionUpdates(to: .main) { motion, error in
            guard let motion else { return }
            let yaw = motion.attitude.yaw
            // Use head rotation for spatial audio or game control.
            print("Head yaw: \(yaw)")
        }
    }
}

Apple Docs: CMHeadphoneMotionManager — Core Motion

Performance Considerations

Update frequency is the biggest lever. A 100 Hz accelerometer stream generates 100 callbacks per second. For UI-driven features (shake detection, step counting display), 10-30 Hz is usually sufficient. Reserve 50-100 Hz for motion-controlled games or AR applications.

Use the motion coprocessor when possible. CMPedometer and CMMotionActivityManager run on the M-series coprocessor, which uses a fraction of the power of the main CPU. Raw CMMotionManager streams run on the main processor. If you only need step counts or activity type, never use CMMotionManager.

Stop sensors when not needed. Every active sensor stream drains battery. Stop updates in viewDidDisappear, sceneDidEnterBackground, or when the feature is no longer visible. A common pattern:

// In your SwiftUI view
.onAppear { tracker.startAccelerometer() }
.onDisappear { tracker.stopAccelerometer() }

Batch processing beats real-time for analytics. If you are computing statistics over motion data (average pace, peak acceleration), buffer samples and process in batches rather than performing calculations on every callback. This reduces main thread pressure and improves energy efficiency.

SensorUpdate SourceBattery ImpactTypical Use
CMPedometerMotion coprocessorNegligibleStep counts, distance, floors
CMMotionActivityManagerMotion coprocessorNegligibleActivity classification
CMAltimeterBarometerVery lowAltitude changes, floor counting
CMMotionManager (accel)Main processorModerateShake detection, tilt, games
CMMotionManager (device)Main processor + fusionModerate to highAR, navigation, gestures

When to Use (and When Not To)

ScenarioRecommendation
Step counting and distanceUse CMPedometer. Most battery-efficient option with pre-processed data.
Detecting walking/running/drivingUse CMMotionActivityManager. Do not process raw accelerometer data for this.
Motion-controlled game mechanicsUse CMMotionManager.deviceMotion for fused, gravity-separated data.
Fitness app with long-term storageRead with Core Motion, write to HealthKit.
AR experiencesUse ARKit instead of raw Core Motion.
Pedometer on Apple WatchUse HealthKit on watchOS, not Core Motion. The APIs differ between platforms.
Background step countingCMPedometer works in the background without special entitlements.

Summary

  • CMPedometer provides battery-free step counts, distance, pace, and floors from the motion coprocessor. Use it for any fitness feature that needs step data.
  • CMMotionManager gives raw accelerometer, gyroscope, and fused device motion data. Create only one instance per app, and choose the lowest update frequency that meets your needs.
  • CMMotionActivityManager classifies user activity (walking, running, cycling, stationary, driving) on dedicated hardware at zero battery cost.
  • CMAltimeter tracks relative altitude changes using the barometric sensor, but its readings drift with weather changes.
  • Always check sensor availability before starting updates, and always stop updates when they are no longer needed.

For persisting motion data beyond the seven-day window, check out HealthKit: Reading Data to learn how to write step counts and workouts to the Health store. If you are using motion data to drive an AR experience, ARKit: Your First AR App covers how ARKit’s visual-inertial odometry builds on top of Core Motion.