How to Build an Accelerometer App in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: CoreMotion Updated: May 12, 2026
TL;DR

Wrap CMMotionManager in an @Observable class, call startAccelerometerUpdates(to: .main) in onAppear, and read data.acceleration.x/y/z in the closure — SwiftUI re-renders automatically on every update.

import CoreMotion
import SwiftUI

@Observable
final class MotionManager {
    var x: Double = 0
    var y: Double = 0
    var z: Double = 0
    private let cm = CMMotionManager()

    func start() {
        cm.accelerometerUpdateInterval = 0.05        // 20 Hz
        cm.startAccelerometerUpdates(to: .main) { [weak self] data, _ in
            guard let self, let data else { return }
            self.x = data.acceleration.x
            self.y = data.acceleration.y
            self.z = data.acceleration.z
        }
    }

    func stop() { cm.stopAccelerometerUpdates() }
}

Full implementation

The complete example separates concerns into a MotionManager observable and a reusable AxisRow view. Each axis gets an animated progress bar calibrated to ±1g, and the scalar magnitude is shown prominently in the centre with a contentTransition(.numericText()) animation. ContentUnavailableView handles the simulator gracefully where no hardware is present.

import SwiftUI
import CoreMotion

// MARK: - MotionManager

@Observable
final class MotionManager {
    var acceleration: CMAcceleration = CMAcceleration(x: 0, y: 0, z: 0)
    var isActive = false

    private let manager = CMMotionManager()
    var isAvailable: Bool { manager.isAccelerometerAvailable }

    func start() {
        guard isAvailable, !isActive else { return }
        manager.accelerometerUpdateInterval = 0.05        // 20 Hz
        manager.startAccelerometerUpdates(to: .main) { [weak self] data, error in
            guard let self, let data, error == nil else { return }
            self.acceleration = data.acceleration
            self.isActive = true
        }
    }

    func stop() {
        manager.stopAccelerometerUpdates()
        isActive = false
    }
}

// MARK: - AxisRow

struct AxisRow: View {
    let label: String
    let value: Double
    let color: Color

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            HStack {
                Text(label)
                    .font(.headline).foregroundStyle(color)
                Spacer()
                Text(String(format: "%+.3f g", value))
                    .font(.system(.body, design: .monospaced))
                    .accessibilityLabel("\(label) axis: \(String(format: "%.2f", value)) g")
            }
            GeometryReader { geo in
                ZStack(alignment: .leading) {
                    Capsule().fill(Color.secondary.opacity(0.15)).frame(height: 10)
                    Capsule()
                        .fill(color)
                        .frame(width: geo.size.width * min(abs(value), 1.0), height: 10)
                        .animation(.easeOut(duration: 0.05), value: value)
                }
            }
            .frame(height: 10)
        }
    }
}

// MARK: - AccelerometerView

struct AccelerometerView: View {
    @State private var motion = MotionManager()

    private var magnitude: Double {
        let a = motion.acceleration
        return sqrt(a.x * a.x + a.y * a.y + a.z * a.z)
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 36) {
                    if motion.isAvailable {
                        VStack(spacing: 20) {
                            AxisRow(label: "X", value: motion.acceleration.x, color: .red)
                            AxisRow(label: "Y", value: motion.acceleration.y, color: .green)
                            AxisRow(label: "Z", value: motion.acceleration.z, color: .blue)
                        }
                        .padding(.horizontal)

                        VStack(spacing: 4) {
                            Text("Magnitude")
                                .font(.caption).foregroundStyle(.secondary)
                            Text(String(format: "%.3f g", magnitude))
                                .font(.system(size: 52, weight: .bold, design: .monospaced))
                                .contentTransition(.numericText())
                                .animation(.easeOut(duration: 0.05), value: magnitude)
                                .accessibilityLabel("Total magnitude: \(String(format: "%.2f", magnitude)) g")
                        }
                    } else {
                        ContentUnavailableView(
                            "Accelerometer Unavailable",
                            systemImage: "exclamationmark.triangle.fill",
                            description: Text("Run on a physical iPhone or iPad.")
                        )
                    }
                }
                .padding(.vertical, 24)
            }
            .navigationTitle("Accelerometer")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button(motion.isActive ? "Stop" : "Start") {
                        motion.isActive ? motion.stop() : motion.start()
                    }
                    .buttonStyle(.borderedProminent)
                    .accessibilityLabel(motion.isActive ? "Stop accelerometer" : "Start accelerometer")
                }
            }
        }
        .onAppear  { motion.start() }
        .onDisappear { motion.stop() }
    }
}

#Preview {
    AccelerometerView()
}

How it works

  1. 1
    @Observable MotionManager — The @Observable macro (iOS 17+) makes any stored property automatically tracked by SwiftUI. No @Published or manual objectWillChange.send() needed. The single CMMotionManager instance lives for the lifetime of the class — never create it inside a View body.
  2. 2
    startAccelerometerUpdates(to: .main) — Delivering updates on OperationQueue.main ensures property writes happen on the main actor, which is where SwiftUI reads them. The 0.05 s interval (20 Hz) gives smooth animations without excessive CPU wake-ups.
  3. 3
    GeometryReader bar gaugeAxisRow uses GeometryReader to compute the bar width as a fraction of available space, clamped with min(abs(value), 1.0) so values beyond ±1g don't overflow. A 0.05 s .easeOut animation matches the sensor interval.
  4. 4
    contentTransition(.numericText()) — Applied to the magnitude Text, this iOS 17+ transition animates digit-by-digit rather than cross-fading the whole label, giving a natural "counter" feel that degrades gracefully at high update rates.
  5. 5
    Lifecycle with onAppear / onDisappear — Starting in onAppear and stopping in onDisappear ties sensor activity to view visibility, preventing battery drain when the user navigates away or backgrounds the app.

Variants

Gravity-corrected user acceleration with CMDeviceMotion

Raw accelerometer data includes the ~1g gravitational component. Use CMDeviceMotion to get userAcceleration (gravity subtracted) and the gravity vector separately — useful for step counting or tilt-compensated gestures.

@Observable
final class DeviceMotionManager {
    var userAcceleration: CMAcceleration = CMAcceleration(x: 0, y: 0, z: 0)
    var gravity: CMAcceleration = CMAcceleration(x: 0, y: 0, z: 0)
    private let manager = CMMotionManager()

    func start() {
        guard manager.isDeviceMotionAvailable else { return }
        manager.deviceMotionUpdateInterval = 0.05
        // .xArbitraryZVertical keeps Z aligned with world gravity
        manager.startDeviceMotionUpdates(
            using: .xArbitraryZVertical,
            to: .main
        ) { [weak self] data, _ in
            guard let self, let data else { return }
            self.userAcceleration = data.userAcceleration   // gravity removed
            self.gravity = data.gravity                      // unit gravity vector
        }
    }

    func stop() { manager.stopDeviceMotionUpdates() }
}

Shake detection without CMMotionManager

For a simple shake gesture, SwiftUI (backed by UIKit's responder chain) already provides motionBegan(_:with:). Bridge it with a UIViewRepresentable shake-receiving view or detect it by thresholding userAcceleration magnitude above ~2.5g in the DeviceMotion handler — no additional setup needed, and it works with standard accessibility events.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement an accelerometer app in SwiftUI for iOS 17+.
Use CoreMotion — specifically CMMotionManager with a 20 Hz update interval.
Show live x, y, z values as animated bar gauges and display total magnitude.
Make it accessible (VoiceOver labels on each axis and magnitude).
Stop updates in onDisappear and handle the simulator gracefully with ContentUnavailableView.
Add a #Preview with realistic sample data.

Drop this prompt directly into the Build phase in Soarias — it will scaffold the MotionManager observable and the SwiftUI views in one shot, leaving you to iterate on the visual design in subsequent turns.

Related

FAQ

Does this work on iOS 16?

The CMMotionManager API itself is available back to iOS 4, but the @Observable macro requires iOS 17+. For iOS 16 compatibility, replace @Observable with ObservableObject, mark each property @Published, and use @StateObject in the view. Also swap ContentUnavailableView for a plain VStack fallback.

What is the difference between raw accelerometer data and CMDeviceMotion?

Raw CMAccelerometerData includes gravity (~1g pulling down), so a flat stationary phone still reads approximately (0, 0, −1). CMDeviceMotion uses sensor fusion (accelerometer + gyroscope) to separate gravity from userAcceleration, which measures only the motion you apply. Use raw data for tilt detection; use DeviceMotion for gesture recognition and step counting.

What is the UIKit equivalent?

UIKit offers motionBegan(_:with:) / motionEnded(_:with:) on UIResponder for basic shake events, but for continuous accelerometer data the API is identical — you use the same CMMotionManager in a UIViewController and call the same startAccelerometerUpdates method. CoreMotion is framework-agnostic and works equally in UIKit and SwiftUI.

Last reviewed: 2026-05-12 by the Soarias team.