```html How to Build a Compass App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

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

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.

```