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
- CMPedometer: Steps, Distance, and Floors
- CMMotionManager: Accelerometer, Gyroscope, and Device Motion
- CMMotionActivityManager: What Is the User Doing?
- CMAltimeter: Relative Altitude Changes
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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
fromparameter instartUpdates(from:)determines the baseline. PassDate()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
CMMotionManagerper 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:
userAccelerationin device motion has gravity subtracted out. If Mike is holding the phone still,userAccelerationwill 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:
CMAltimeterprovides relative altitude, not absolute. The reference point is zero at the moment you callstartRelativeAltitudeUpdates. 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.
| Sensor | Update Source | Battery Impact | Typical Use |
|---|---|---|---|
CMPedometer | Motion coprocessor | Negligible | Step counts, distance, floors |
CMMotionActivityManager | Motion coprocessor | Negligible | Activity classification |
CMAltimeter | Barometer | Very low | Altitude changes, floor counting |
CMMotionManager (accel) | Main processor | Moderate | Shake detection, tilt, games |
CMMotionManager (device) | Main processor + fusion | Moderate to high | AR, navigation, gestures |
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Step counting and distance | Use CMPedometer. Most battery-efficient option with pre-processed data. |
| Detecting walking/running/driving | Use CMMotionActivityManager. Do not process raw accelerometer data for this. |
| Motion-controlled game mechanics | Use CMMotionManager.deviceMotion for fused, gravity-separated data. |
| Fitness app with long-term storage | Read with Core Motion, write to HealthKit. |
| AR experiences | Use ARKit instead of raw Core Motion. |
| Pedometer on Apple Watch | Use HealthKit on watchOS, not Core Motion. The APIs differ between platforms. |
| Background step counting | CMPedometer works in the background without special entitlements. |
Summary
CMPedometerprovides battery-free step counts, distance, pace, and floors from the motion coprocessor. Use it for any fitness feature that needs step data.CMMotionManagergives raw accelerometer, gyroscope, and fused device motion data. Create only one instance per app, and choose the lowest update frequency that meets your needs.CMMotionActivityManagerclassifies user activity (walking, running, cycling, stationary, driving) on dedicated hardware at zero battery cost.CMAltimetertracks 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.