How to implement gyroscope tracking in SwiftUI
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
-
@Observablewraps CMMotionManager. MarkingGyroscopeModelwith@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 manualobjectWillChangeneeded. -
gyroUpdateInterval = 1.0 / 60.0sets 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 to1.0 / 10.0to halve CPU usage. -
startGyroUpdates(to: .main)pushes data via closure. PassingOperationQueue.mainkeeps the handler on the main actor, so directly assigningself.rotationXis safe withoutTask { @MainActor in … }gymnastics. -
AxisRowmaps radians/second to a bar width. Values are clamped to±10 rad/s(a conservative ceiling for wrist motion) and normalised to fill the availableGeometryReaderwidth, giving an intuitive visual without any charts dependency. -
onDisappear { model.stopUpdates() }cleans up. Forgetting this call leaves the gyroscope sensor running in the background, draining battery and generating pointless CPU work. TheonAppear/onDisappearpair 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
-
iOS 17+ only for
@Observable— use@StateObjecton iOS 16. If your deployment target is below iOS 17, replace@ObservablewithObservableObject+@Publishedproperties and inject the model with@StateObject. The sensor API itself works back to iOS 4. -
Never create multiple
CMMotionManagerinstances. Apple's docs explicitly state one instance per app. If multiple views need gyro data, share a single instance via the environment or a singleton. A second instance may silently receive no data. -
Gyroscope is unavailable on some iPads and all Simulators. Always guard with
manager.isGyroAvailablebefore starting updates, and show aContentUnavailableViewrather than an empty or broken state. The Simulator always returnsfalsefor availability — test on a physical device. - High update rates inflate CPU and battery use significantly. 60 Hz is appropriate for game-like UIs. For tilt-based navigation or accessibility features, 10–15 Hz is plenty and reduces power draw by ~75 %.
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.