How to Implement a Compass in SwiftUI
Use CLLocationManager with startUpdatingHeading() to stream
CLHeading values, then bind magneticHeading
to a SwiftUI .rotationEffect() for a live animated needle.
import CoreLocation
@Observable
final class CompassManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
var heading: Double = 0
override init() {
super.init()
manager.delegate = self
manager.requestWhenInUseAuthorization()
}
func locationManager(_ manager: CLLocationManager,
didUpdateHeading h: CLHeading) {
heading = h.magneticHeading
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if manager.authorizationStatus == .authorizedWhenInUse {
manager.startUpdatingHeading()
}
}
}
Full implementation
The approach wraps CLLocationManager in an
@Observable class so SwiftUI views automatically re-render on each heading update.
A custom Triangle shape forms a two-tone needle, and a
.rotationEffect with an eased animation turns raw degrees into smooth motion.
Cardinal labels are stamped around a circle ring and remain fixed while the needle rotates beneath them.
import SwiftUI
import CoreLocation
// MARK: - Data layer
@Observable
final class CompassManager: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
var magneticHeading: Double = 0
var headingAccuracy: Double = -1 // -1 means unavailable
var isAvailable = false
override init() {
super.init()
manager.delegate = self
isAvailable = CLLocationManager.headingAvailable()
manager.requestWhenInUseAuthorization()
}
func stop() { manager.stopUpdatingHeading() }
// CLLocationManagerDelegate
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard isAvailable else { return }
switch manager.authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways:
manager.startUpdatingHeading()
default:
break
}
}
func locationManager(_ manager: CLLocationManager,
didUpdateHeading newHeading: CLHeading) {
guard newHeading.headingAccuracy >= 0 else { return }
magneticHeading = newHeading.magneticHeading
headingAccuracy = newHeading.headingAccuracy
}
}
// MARK: - Needle shape
struct CompassNeedle: View {
var body: some View {
VStack(spacing: 0) {
TriangleShape()
.fill(.red)
.frame(width: 18, height: 76)
TriangleShape()
.fill(Color(.systemGray4))
.frame(width: 18, height: 76)
.rotationEffect(.degrees(180))
}
}
}
struct TriangleShape: Shape {
func path(in rect: CGRect) -> Path {
Path { p in
p.move(to: CGPoint(x: rect.midX, y: rect.minY))
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
p.closeSubpath()
}
}
}
// MARK: - Compass view
struct CompassView: View {
@State private var compass = CompassManager()
private let cardinals = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
var cardinalLabel: String {
let idx = Int((compass.magneticHeading + 22.5) / 45.0) % 8
return cardinals[idx]
}
var body: some View {
VStack(spacing: 28) {
Text("Compass")
.font(.largeTitle.bold())
ZStack {
// Outer ring
Circle()
.strokeBorder(Color.secondary.opacity(0.25), lineWidth: 1.5)
.frame(width: 260, height: 260)
// Cardinal labels (fixed in place)
ForEach(Array(zip(["N","E","S","W"], [0.0, 90.0, 180.0, 270.0])),
id: \.0) { label, angle in
Text(label)
.font(.caption.bold())
.foregroundStyle(label == "N" ? Color.red : Color.primary)
.offset(y: -110)
.rotationEffect(.degrees(angle))
.accessibilityHidden(true)
}
// Tick marks
ForEach(0..<36) { i in
let isMajor = i % 9 == 0
Rectangle()
.fill(Color.secondary.opacity(isMajor ? 0.6 : 0.25))
.frame(width: isMajor ? 2 : 1, height: isMajor ? 12 : 6)
.offset(y: -120)
.rotationEffect(.degrees(Double(i) * 10))
}
// Pivot cap
Circle()
.fill(Color(.systemBackground))
.frame(width: 16, height: 16)
.shadow(radius: 2)
// Animated needle
CompassNeedle()
.rotationEffect(.degrees(-compass.magneticHeading))
.animation(.easeOut(duration: 0.25), value: compass.magneticHeading)
.accessibilityLabel("Compass needle pointing \(cardinalLabel)")
}
.frame(width: 260, height: 260)
// Readout
VStack(spacing: 6) {
Text(cardinalLabel)
.font(.title.bold())
.contentTransition(.numericText())
Text(String(format: "%.1f°", compass.magneticHeading))
.font(.title3)
.monospacedDigit()
.foregroundStyle(.secondary)
.contentTransition(.numericText())
if compass.headingAccuracy > 0 {
Text(String(format: "±%.0f° accuracy", compass.headingAccuracy))
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.animation(.default, value: compass.magneticHeading)
}
.padding()
.onDisappear { compass.stop() }
}
}
#Preview {
CompassView()
}
How it works
-
@Observable CompassManager — Marking the class with
@Observable(iOS 17+) means any SwiftUI view that readsmagneticHeadingautomatically invalidates and re-renders when the delegate fires, with no@Publishedboilerplate. -
Authorization → heading start —
requestWhenInUseAuthorization()triggers the system prompt. The delegate callbacklocationManagerDidChangeAuthorizationthen callsstartUpdatingHeading()only after authorization is confirmed, avoiding a silent no-op. -
Accuracy guard —
headingAccuracy < 0signals that the heading is unreliable (e.g., the device is near a magnet). The delegate skips those readings so the needle never jumps to garbage values. -
Inverse rotation —
.rotationEffect(.degrees(-compass.magneticHeading))rotates the needle opposite to the device's orientation so the "N" tip always tracks geographic north relative to the screen. -
Fixed cardinal labels — The "N/E/S/W" labels use a fixed
rotationEffectat 0°, 90°, 180°, 270° and sit outside the needle'sZStacklayer so they never rotate with the needle.
Variants
True North instead of Magnetic North
magneticHeading is relative to the magnetic pole.
For navigation, switch to trueHeading, but you must also
enable location updates so CoreLocation can apply the local declination.
// In CompassManager.init():
manager.desiredAccuracy = kCLLocationAccuracyBest
manager.startUpdatingLocation() // needed for declination
manager.startUpdatingHeading()
// In locationManager(_:didUpdateHeading:):
// trueHeading is -1 when location is unavailable
let degrees = newHeading.trueHeading >= 0
? newHeading.trueHeading
: newHeading.magneticHeading
magneticHeading = degrees
Heading filter to reduce jitter
Set manager.headingFilter = 2 (degrees) so the delegate only fires when the
heading changes by at least 2°. This cuts CPU and animation churn on a still device without noticeably lagging a moving one.
The default is kCLHeadingFilterNone, which fires on every tiny fluctuation.
Common pitfalls
-
Missing Info.plist key. Heading updates silently fail if
NSLocationWhenInUseUsageDescriptionis absent. Add it in Target → Info or yourInfo.plist; without it the authorization prompt never appears andstartUpdatingHeading()is a no-op. -
Simulator always returns 0°.
CLLocationManager.headingAvailable()returnsfalsein Simulator. Gate your UI so it shows a "Compass not available on this device" message whenisAvailableis false, and always test on real hardware. -
Stop updates off-screen. Heading streaming keeps the GPS and magnetometer active in the background.
Always call
manager.stopUpdatingHeading()in.onDisappear(done in the example) to prevent battery drain when the view is off-screen. -
VoiceOver needs a live label. The needle image alone is meaningless to assistive technology. Use
.accessibilityLabel("Compass needle pointing \(cardinalLabel)")so VoiceOver announces the direction when the user focuses the compass.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a compass in SwiftUI for iOS 17+. Use CLHeading and CLLocationManager with @Observable. Show magneticHeading as a rotating needle shape with cardinal labels fixed around a circle. Display headingAccuracy below the readout. Make it accessible (VoiceOver labels on the needle). Stop heading updates in .onDisappear. Add a #Preview with realistic sample data.
In the Soarias Build phase, paste this prompt into the active screen scaffold and Claude Code will wire CoreLocation authorization, the heading delegate, and SwiftUI animation in a single diff — ready to compile and run on device.
Related guides
FAQ
Does this work on iOS 16?
The CoreLocation heading API works back to iOS 4, but the @Observable macro requires iOS 17+.
On iOS 16 you would replace @Observable with
ObservableObject and mark heading with
@Published. However, Soarias-generated apps target iOS 17+ by default, so this guide uses the modern macro.
Why does the needle spin wildly near metal or magnets?
CLHeading.headingAccuracy is the key signal — values above ~15° indicate environmental interference.
Display a warning badge when accuracy exceeds your threshold, and optionally pause animation to avoid a disorienting spinning needle:
wrap the rotationEffect in a conditional that freezes it when
headingAccuracy > 20.
What's the UIKit equivalent?
In UIKit you'd use the same CLLocationManager delegate, then apply a
CGAffineTransform.init(rotationAngle:) (in radians) to a
UIImageView inside a UIView.animate block.
SwiftUI's .rotationEffect + .animation achieves the same result in far fewer lines.
Last reviewed: 2026-05-12 by the Soarias team.