```html SwiftUI: How to Implement Gyroscope Tracking (iOS 17+, 2026)

How to implement gyroscope tracking in SwiftUI

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

Create a single CMMotionManager, call startGyroUpdates(to:withHandler:) to stream rotation-rate data on a background queue, and publish those values to an @Observable model that drives your SwiftUI view. Stop updates in onDisappear to preserve battery.

import CoreMotion
import SwiftUI

@Observable final class GyroModel {
    var x = 0.0, y = 0.0, z = 0.0
    private let manager = CMMotionManager()

    func start() {
        guard manager.isGyroAvailable else { return }
        manager.gyroUpdateInterval = 1.0 / 60.0
        manager.startGyroUpdates(to: .main) { [weak self] data, _ in
            guard let r = data?.rotationRate else { return }
            self?.x = r.x; self?.y = r.y; self?.z = r.z
        }
    }

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

Full implementation

The pattern below wraps CMMotionManager inside an @Observable class so SwiftUI automatically re-renders whenever rotation values change. The manager is created once, its update interval is set to 60 Hz (matching the display refresh rate), and push updates flow through a completion handler on the main queue. A VStack visualises the three rotation-rate axes and a simple animated arc shows the Z-axis spin graphically.

import CoreMotion
import SwiftUI

// MARK: - Observable Model

@Observable
final class GyroscopeModel {
    var rotationX: Double = 0
    var rotationY: Double = 0
    var rotationZ: Double = 0
    var isAvailable: Bool = false

    private let manager = CMMotionManager()

    init() {
        isAvailable = manager.isGyroAvailable
    }

    func startUpdates() {
        guard manager.isGyroAvailable else { return }
        manager.gyroUpdateInterval = 1.0 / 60.0          // 60 Hz
        manager.startGyroUpdates(to: .main) { [weak self] data, error in
            guard let self, let rate = data?.rotationRate, error == nil else { return }
            self.rotationX = rate.x
            self.rotationY = rate.y
            self.rotationZ = rate.z
        }
    }

    func stopUpdates() {
        manager.stopGyroUpdates()
    }
}

// MARK: - Axis Row

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

    var body: some View {
        HStack {
            Text(label)
                .font(.system(.body, design: .monospaced))
                .foregroundStyle(color)
                .frame(width: 24, alignment: .leading)
                .accessibilityLabel("\(label) axis")

            GeometryReader { geo in
                let clamped = (value / 10.0).clamped(to: -1...1)
                let barWidth = abs(clamped) * geo.size.width / 2

                ZStack(alignment: clamped >= 0 ? .leading : .trailing) {
                    RoundedRectangle(cornerRadius: 4)
                        .fill(Color.secondary.opacity(0.15))
                    HStack(spacing: 0) {
                        if clamped < 0 { Spacer() }
                        RoundedRectangle(cornerRadius: 4)
                            .fill(color.opacity(0.8))
                            .frame(width: barWidth)
                        if clamped >= 0 { Spacer() }
                    }
                }
            }
            .frame(height: 14)

            Text(String(format: "%+.2f", value))
                .font(.system(.caption, design: .monospaced))
                .foregroundStyle(.secondary)
                .frame(width: 64, alignment: .trailing)
                .accessibilityLabel(String(format: "%.2f radians per second", value))
        }
    }
}

// MARK: - Spin Indicator

private struct SpinIndicator: View {
    let rotationZ: Double
    @State private var angle: Double = 0

    var body: some View {
        ZStack {
            Circle()
                .stroke(Color.secondary.opacity(0.2), lineWidth: 2)
            Image(systemName: "arrow.triangle.2.circlepath")
                .font(.system(size: 36))
                .foregroundStyle(.purple)
                .rotationEffect(.radians(angle))
        }
        .frame(width: 90, height: 90)
        .accessibilityLabel("Spin indicator, Z rotation \(String(format: "%.1f", rotationZ)) rad/s")
        .onChange(of: rotationZ) { _, newValue in
            angle += newValue * 0.05
        }
    }
}

// MARK: - Main View

struct GyroscopeView: View {
    @State private var model = GyroscopeModel()

    var body: some View {
        NavigationStack {
            VStack(spacing: 32) {
                if model.isAvailable {
                    SpinIndicator(rotationZ: model.rotationZ)

                    VStack(spacing: 12) {
                        AxisRow(label: "X", value: model.rotationX, color: .red)
                        AxisRow(label: "Y", value: model.rotationY, color: .green)
                        AxisRow(label: "Z", value: model.rotationZ, color: .purple)
                    }
                    .padding(.horizontal)

                    Text("Rotate your device to see live gyroscope data.")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                        .multilineTextAlignment(.center)
                        .padding(.horizontal)
                } else {
                    ContentUnavailableView(
                        "Gyroscope Unavailable",
                        systemImage: "gyroscope",
                        description: Text("This device does not have a gyroscope.")
                    )
                }
            }
            .navigationTitle("Gyroscope")
            .onAppear  { model.startUpdates() }
            .onDisappear { model.stopUpdates() }
        }
    }
}

// MARK: - Helpers

private extension Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self {
        min(max(self, range.lowerBound), range.upperBound)
    }
}

// MARK: - Preview

#Preview {
    GyroscopeView()
}

How it works

  1. @Observable wraps CMMotionManager. Marking GyroscopeModel with @Observable (Swift 5.9 Observation framework, required iOS 17+) means SwiftUI automatically tracks which properties are read by each view body and only re-renders those views when values actually change — no manual objectWillChange needed.
  2. gyroUpdateInterval = 1.0 / 60.0 sets the data rate. This requests data at 60 Hz, matching the standard ProMotion refresh boundary. For less demanding UIs (e.g., tilt-based layouts) you can lower this to 1.0 / 10.0 to halve CPU usage.
  3. startGyroUpdates(to: .main) pushes data via closure. Passing OperationQueue.main keeps the handler on the main actor, so directly assigning self.rotationX is safe without Task { @MainActor in … } gymnastics.
  4. AxisRow maps radians/second to a bar width. Values are clamped to ±10 rad/s (a conservative ceiling for wrist motion) and normalised to fill the available GeometryReader width, giving an intuitive visual without any charts dependency.
  5. onDisappear { model.stopUpdates() } cleans up. Forgetting this call leaves the gyroscope sensor running in the background, draining battery and generating pointless CPU work. The onAppear / onDisappear pair is the correct SwiftUI lifecycle hook for sensor start/stop.

Variants

Fuse gyroscope + accelerometer with Device Motion

Raw gyroscope data drifts over time. Use CMDeviceMotion instead for sensor-fused, drift-corrected attitude (roll, pitch, yaw). This is what ARKit and game engines rely on internally.

@Observable
final class DeviceMotionModel {
    var roll:  Double = 0
    var pitch: Double = 0
    var yaw:   Double = 0

    private let manager = CMMotionManager()

    func start() {
        guard manager.isDeviceMotionAvailable else { return }
        manager.deviceMotionUpdateInterval = 1.0 / 60.0
        manager.startDeviceMotionUpdates(
            using: .xMagneticNorthZVertical,   // reference frame
            to: .main
        ) { [weak self] motion, _ in
            guard let self, let attitude = motion?.attitude else { return }
            self.roll  = attitude.roll
            self.pitch = attitude.pitch
            self.yaw   = attitude.yaw
        }
    }

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

// Usage in view:
// Text("Yaw: \(String(format: "%.2f°", model.yaw * 180 / .pi))")

Threshold-based shake / flick detection

Accumulate the magnitude sqrt(x² + y² + z²) inside the update handler and fire a Haptic feedback when it crosses a threshold (e.g., 8.0 rad/s). This is far more reliable than UIResponder.motionBegan(_:with:), which only detects multi-axis shakes via the accelerometer. Use UIImpactFeedbackGenerator(style: .heavy).impactOccurred() from within the main-thread callback for instant tactile response.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement gyroscope tracking in SwiftUI for iOS 17+.
Use CMMotionManager with startGyroUpdates(to:withHandler:).
Wrap the manager in an @Observable class; update x, y, z rotation
rates at 60 Hz on the main queue.
Stop updates in onDisappear.
Make it accessible (VoiceOver labels for each axis value).
Add a #Preview with realistic sample data.

Paste this prompt directly into Soarias during the Build phase — Claude Code will scaffold the @Observable model, the axis visualisation, and the preview in one shot, leaving you to tune visual styling and sensor frequency for your specific use case.

Related

FAQ

Does this work on iOS 16?

The CMMotionManager gyroscope API works back to iOS 4, so the sensor code itself is fully compatible. The @Observable macro introduced in iOS 17 does not compile on iOS 16 — swap it for class GyroscopeModel: ObservableObject with @Published var rotationX properties, and inject it with @StateObject in your view.

How do I convert raw rad/s values into useful device orientation?

Raw gyroscope values are angular velocities — they tell you how fast the device is rotating, not where it is pointing. To get absolute orientation (pitch, roll, yaw relative to gravity and north), switch from startGyroUpdates to startDeviceMotionUpdates(using:to:withHandler:). The returned CMDeviceMotion.attitude property provides sensor-fused, drift-corrected angles that are far more suitable for UI tilt effects, game controllers, or compass-relative features.

What's the UIKit equivalent?

The underlying API is identical — CMMotionManager is a CoreMotion class used the same way in UIKit. The only difference is lifecycle: instead of onAppear / onDisappear, call startGyroUpdates in viewDidAppear and stopGyroUpdates in viewDidDisappear. UIKit developers sometimes also use UIResponder.motionBegan(_:with:) for shake detection, but CMMotionManager gives far more granular control.

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

```