How to Build a Compass App in SwiftUI
A compass app reads the device's magnetometer via CoreLocation and displays a real-time heading with a rotating dial — it's an ideal first hardware-sensor project for iOS developers learning SwiftUI.
Prerequisites
- → Mac with Xcode 16+
- → Apple Developer Program ($99/year) — required for TestFlight and App Store
- → Basic Swift/SwiftUI knowledge
- → Physical iPhone for all testing — the Simulator has no magnetometer and never fires heading callbacks
Architecture overview
A compass app has almost no data layer: a single @Observable HeadingManager owns a CLLocationManager, requests heading updates, and exposes the current bearing as a plain Double. The SwiftUI layer binds to that value, rotates a Shape, and shows cardinal labels — no networking, no persistence, just a sensor stream and an animated view.
CompassApp/ ├── HeadingManager.swift # @Observable CLLocationManager wrapper ├── CompassView.swift # Root view: needle + cardinal labels + toggle ├── NeedleView.swift # Rotatable needle built from SwiftUI shapes ├── ContentView.swift # Permission gate → CompassView └── PrivacyInfo.xcprivacy # Required for App Store submission
Step-by-step
1. Data model
Wrap CLLocationManager in an @Observable class so every SwiftUI view that reads heading re-renders automatically on each sensor update.
import CoreLocation
import Observation
@Observable
final class HeadingManager: NSObject, CLLocationManagerDelegate {
var heading: Double = 0
var headingAccuracy: Double = -1
var authStatus: CLAuthorizationStatus = .notDetermined
var useTrueNorth: Bool = false
private let manager = CLLocationManager()
override init() {
super.init()
manager.delegate = self
manager.headingFilter = 1 // fire on every 1° change
}
func start() {
manager.requestWhenInUseAuthorization()
manager.startUpdatingHeading()
}
func locationManagerDidChangeAuthorization(_ m: CLLocationManager) {
authStatus = m.authorizationStatus
}
func locationManager(_ m: CLLocationManager,
didUpdateHeading h: CLHeading) {
headingAccuracy = h.headingAccuracy
heading = useTrueNorth && h.trueHeading >= 0
? h.trueHeading
: h.magneticHeading
}
}
2. Core UI
Stack a degree readout, a circular dial with cardinal labels, and a needle on a ZStack; drive the rotation from the published heading with a short easing animation to smooth jitter.
struct CompassView: View {
@State private var manager = HeadingManager()
@State private var useTrueNorth = false
var body: some View {
VStack(spacing: 28) {
Text("\(Int(manager.heading))°")
.font(.system(size: 60, weight: .bold, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText())
ZStack {
Circle()
.stroke(Color.secondary.opacity(0.15), lineWidth: 1)
.frame(width: 260, height: 260)
ForEach(["N","E","S","W"].indices, id: \.self) { i in
Text(["N","E","S","W"][i])
.font(.caption.bold())
.offset(y: -110)
.rotationEffect(.degrees(Double(i) * 90))
}
NeedleView()
.rotationEffect(.degrees(manager.heading))
.animation(.easeOut(duration: 0.15), value: manager.heading)
}
Toggle("True North", isOn: $useTrueNorth)
.padding(.horizontal, 48)
.onChange(of: useTrueNorth) { _, val in
manager.useTrueNorth = val
}
}
.onAppear { manager.start() }
}
}
3. Magnetic and true north
True north requires an active GPS fix; trueHeading returns -1 until one is available, so always fall back to magnetic heading and surface a low-accuracy warning in the UI.
// NeedleView.swift — simple two-tone needle
struct NeedleView: View {
var body: some View {
VStack(spacing: 0) {
Rectangle()
.fill(.red)
.frame(width: 8, height: 70)
Rectangle()
.fill(Color(.systemGray4))
.frame(width: 8, height: 70)
}
.clipShape(Capsule())
}
}
// Low-accuracy badge — add inside CompassView VStack
if manager.headingAccuracy < 0 || manager.headingAccuracy > 20 {
Label("Move away from metal objects",
systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
4. Privacy Manifest setup
Any app using CoreLocation must ship a PrivacyInfo.xcprivacy file — App Store Connect has rejected submissions missing it since Spring 2024.
<!-- File > New > File > App Privacy in Xcode; add to app target -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryLocation</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>DDA9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
Common pitfalls
- → Testing in the Simulator. CoreLocation never fires
didUpdateHeadingin the Simulator. Set up a real device in Xcode from day one — there is no workaround. - → Vague location usage string. App Review will reject "App uses location" in
NSLocationWhenInUseUsageDescription. Write something user-facing: "Compass uses your location to calculate true north." - → Displaying
trueHeadingbefore a GPS fix.CLHeading.trueHeadingis-1until the device acquires a position. Rendering-1° silently shows a broken compass; always guard and fall back to magnetic. - → Animating every single degree. Setting
headingFilter = 1fires updates rapidly; without an easing animation the needle jitters. Use.animation(.easeOut(duration: 0.15))or setheadingFilter = 2. - → Missing background-location gate during review. If your app never requests
alwaysauthorization but your Privacy Manifest includes a reason for background use, reviewers will flag the mismatch. Only declare what you actually use.
Adding monetization: One-time purchase
A non-consumable StoreKit 2 product (e.g. com.yourapp.compass.pro) is the right fit for a utility like this. Define the product in App Store Connect, then call Product.purchase() in your paywall view and check Transaction.currentEntitlement(for:) on launch to restore access. Gate premium features — larger dial, custom color themes, a bearing-to-destination mode — behind the entitlement. The full purchase and restore flow is around 25 lines with StoreKit 2's async/await API; no third-party library is needed.
Shipping this faster with Soarias
Soarias scaffolds the full project from a prompt: it generates HeadingManager, writes a complete and reviewer-safe NSLocationWhenInUseUsageDescription into Info.plist, creates PrivacyInfo.xcprivacy with the correct reason code, and configures fastlane lanes for both TestFlight and production App Store delivery — before you write a single line of heading logic.
For a beginner project like this compass app, Soarias typically compresses the journey from empty Xcode project to live TestFlight build from a full weekend down to a few focused hours. You spend your time on the needle animation and UX polish; Soarias handles App Store Connect metadata, screenshot uploads, and the first submission.
Related guides
FAQ
Do I need a paid Apple Developer account?
A free Apple ID lets you sideload the app onto your own device via Xcode for testing. You need the $99/year Apple Developer Program membership to distribute via TestFlight or publish on the App Store.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), upload through the Xcode Organizer, then complete the App Store Connect listing: screenshots, privacy nutrition labels, and review notes. Make sure your location usage description is specific — vague strings are a common first-rejection reason for utility apps.
Does a compass app have significant battery impact?
Heading updates are magnetometer-only and have negligible battery cost compared to GPS. That said, call manager.stopUpdatingHeading() when the app moves to the background (scenePhase == .background) — it avoids any background-location scrutiny during App Review and is simply good citizenship.
Last reviewed: 2026-05-12 by the Soarias team.