```html SwiftUI: How to Implement Compass (iOS 17+, 2026)
Soarias ← All SwiftUI guides

How to Implement a Compass in SwiftUI

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

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

  1. @Observable CompassManager — Marking the class with @Observable (iOS 17+) means any SwiftUI view that reads magneticHeading automatically invalidates and re-renders when the delegate fires, with no @Published boilerplate.
  2. Authorization → heading startrequestWhenInUseAuthorization() triggers the system prompt. The delegate callback locationManagerDidChangeAuthorization then calls startUpdatingHeading() only after authorization is confirmed, avoiding a silent no-op.
  3. Accuracy guardheadingAccuracy < 0 signals 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.
  4. 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.
  5. Fixed cardinal labels — The "N/E/S/W" labels use a fixed rotationEffect at 0°, 90°, 180°, 270° and sit outside the needle's ZStack layer 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

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.

```