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

How to Build a Bubble Level App in SwiftUI

A Bubble Level app reads raw gravity data from the device accelerometer via CoreMotion and renders an animated bubble that moves in real time as the user tilts a phone or tablet — perfect for hanging pictures, assembling furniture, or checking that a surface is plumb. It's one of the clearest beginner projects for learning CoreMotion because the sensor output maps directly to a visual result with minimal business logic in the way.

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

Prerequisites

Architecture overview

The app has no persistence layer — all state is ephemeral sensor data. MotionManager is an @Observable class that owns a CMMotionManager instance, subscribes to device-motion updates at 60 Hz on the main queue, and exposes gravityX and gravityY as published doubles in the range −1 to 1. BubbleView is a pure SwiftUI view that converts those doubles into a clamped pixel offset and animates the bubble with an interactive spring. ContentView owns the MotionManager state object, composes the two views, and fires a haptic tap the moment the surface reads as level.

BubbleLevel/
├── App/
│   └── BubbleLevelApp.swift      ← @main entry point
├── Core/
│   └── MotionManager.swift       ← @Observable, CMMotionManager
├── Views/
│   ├── ContentView.swift         ← root screen, owns MotionManager
│   └── BubbleView.swift          ← pure view, gravity → pixel offset
├── Info.plist                    ← NSMotionUsageDescription required
└── PrivacyInfo.xcprivacy         ← App Store privacy manifest
      

Step-by-step

1. Create the Xcode project

In Xcode 16 choose File › New › Project, pick the App template under iOS, set the interface to SwiftUI, and leave Storage as None — this app needs no database. After the project opens, select Info.plist, add the key NSMotionUsageDescription, and give it a plain-English value such as "Used to detect device tilt for the level display." Without this key iOS will terminate the app with a privacy violation on first motion access.

// BubbleLevelApp.swift
import SwiftUI

@main
struct BubbleLevelApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

2. Build the MotionManager

Create a new Swift file called MotionManager.swift. The class uses the @Observable macro from the Observation framework (iOS 17+) so SwiftUI views automatically re-render whenever gravityX or gravityY changes — no @Published needed. Polling data.gravity rather than raw attitude avoids reference-frame headaches: when the device lies flat on a table, both components read near zero regardless of compass heading.

// MotionManager.swift
import CoreMotion
import Observation

@Observable
final class MotionManager {
    var gravityX: Double = 0
    var gravityY: Double = 0

    var isLevel: Bool {
        abs(gravityX) < 0.03 && abs(gravityY) < 0.03
    }

    private let manager = CMMotionManager()

    func start() {
        guard manager.isDeviceMotionAvailable else { return }
        manager.deviceMotionUpdateInterval = 1.0 / 60.0
        manager.startDeviceMotionUpdates(to: .main) { [weak self] data, _ in
            guard let data, let self else { return }
            self.gravityX = data.gravity.x
            self.gravityY = data.gravity.y
        }
    }

    func stop() {
        manager.stopDeviceMotionUpdates()
    }
}

3. Build the BubbleView

BubbleView is a stateless view that takes three inputs — gravityX, gravityY, and isLevel — and converts them to a clamped offset within a circular container. Negating gravityY before converting to screen offset is important: in CoreMotion, positive Y points toward the top of the device, but SwiftUI's Y-axis grows downward, so an upward tilt would move the bubble the wrong way without the sign flip.

// BubbleView.swift
import SwiftUI

struct BubbleView: View {
    let gravityX: Double
    let gravityY: Double
    let isLevel: Bool

    private let containerSize: CGFloat = 240
    private let bubbleSize:    CGFloat = 48
    private let maxOffset:     CGFloat = 88

    private var offsetX: CGFloat {
        let raw = CGFloat(gravityX) * maxOffset
        return min(max(raw, -maxOffset), maxOffset)
    }

    private var offsetY: CGFloat {
        // Negate: CoreMotion +Y is up, SwiftUI +Y is down
        let raw = CGFloat(-gravityY) * maxOffset
        return min(max(raw, -maxOffset), maxOffset)
    }

    var body: some View {
        ZStack {
            // Background disc
            Circle()
                .fill(Color.secondary.opacity(0.07))
                .frame(width: containerSize, height: containerSize)

            // Outer border ring
            Circle()
                .stroke(Color.secondary.opacity(0.3), lineWidth: 1.5)
                .frame(width: containerSize, height: containerSize)

            // Crosshairs
            Rectangle()
                .fill(Color.secondary.opacity(0.2))
                .frame(width: containerSize, height: 1)
            Rectangle()
                .fill(Color.secondary.opacity(0.2))
                .frame(width: 1, height: containerSize)

            // Level-zone target ring
            Circle()
                .stroke(
                    isLevel ? Color.green : Color.secondary.opacity(0.35),
                    lineWidth: 1.5
                )
                .frame(width: 40, height: 40)

            // Bubble
            Circle()
                .fill(isLevel ? Color.green : Color.yellow)
                .shadow(
                    color: isLevel ? .green.opacity(0.45) : .clear,
                    radius: 10
                )
                .frame(width: bubbleSize, height: bubbleSize)
                .offset(x: offsetX, y: offsetY)
                .animation(
                    .interactiveSpring(response: 0.25, dampingFraction: 0.75),
                    value: offsetX
                )
                .animation(
                    .interactiveSpring(response: 0.25, dampingFraction: 0.75),
                    value: offsetY
                )
        }
        .frame(width: containerSize, height: containerSize)
        .clipShape(Circle())
    }
}

#Preview {
    BubbleView(gravityX: 0.15, gravityY: -0.08, isLevel: false)
        .padding(40)
}

4. Wire up ContentView with live readings

ContentView owns the MotionManager as @State so SwiftUI manages its lifetime, starts and stops polling with onAppear/onDisappear, and drives a numeric degree display derived from the gravity magnitude. The sensoryFeedback modifier fires a success haptic exactly once each time isLevel flips from false to true — a small detail that dramatically improves the "snap-into-place" feel.

// ContentView.swift
import SwiftUI

struct ContentView: View {
    @State private var motion = MotionManager()

    private var tiltDegrees: Double {
        let mag = (motion.gravityX * motion.gravityX
                 + motion.gravityY * motion.gravityY).squareRoot()
        return atan(mag) * 180 / .pi
    }

    var body: some View {
        ZStack {
            Color(uiColor: .systemBackground)
                .ignoresSafeArea()

            VStack(spacing: 44) {
                Text("Level")
                    .font(.system(size: 34, weight: .bold, design: .rounded))

                BubbleView(
                    gravityX: motion.gravityX,
                    gravityY: motion.gravityY,
                    isLevel:  motion.isLevel
                )

                VStack(spacing: 6) {
                    if motion.isLevel {
                        Text("LEVEL")
                            .font(.title2.bold())
                            .foregroundStyle(.green)
                            .transition(.opacity.combined(with: .scale))
                    } else {
                        Text(String(format: "%.1f°", tiltDegrees))
                            .font(.system(
                                size: 32,
                                weight: .semibold,
                                design: .monospaced
                            ))
                            .contentTransition(.numericText())
                            .animation(.default, value: tiltDegrees)
                    }

                    Text("Place device flat on the surface")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
        }
        .onAppear  { motion.start() }
        .onDisappear { motion.stop() }
        .sensoryFeedback(.success, trigger: motion.isLevel)
        .animation(.easeInOut(duration: 0.2), value: motion.isLevel)
    }
}

#Preview {
    ContentView()
}

5. Add the Privacy Manifest

Apple has required a PrivacyInfo.xcprivacy file in every App Store submission since spring 2024. In Xcode choose File › New › File from Template, search for App Privacy, and save it as PrivacyInfo.xcprivacy at the project root. A Bubble Level app collects no personal data and calls none of Apple's "required reason" APIs (CoreMotion device motion is not on that list), so the manifest is minimal. Confirm that NSPrivacyTracking is false and both array keys are empty — the Xcode editor will show these as checkboxes and empty tables.

<?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>
    <!-- This app does not track users -->
    <key>NSPrivacyTracking</key>
    <false/>

    <!-- No tracking domains -->
    <key>NSPrivacyTrackingDomains</key>
    <array/>

    <!-- No data types collected -->
    <key>NSPrivacyCollectedDataTypes</key>
    <array/>

    <!-- No required-reason APIs used -->
    <key>NSPrivacyAccessedAPITypes</key>
    <array/>
</dict>
</plist>

Common pitfalls

Adding monetization: One-time purchase

The simplest model is a paid upfront app: set a price tier in App Store Connect under Pricing and Availability (typically $0.99–$1.99 for a utility this size) and the App Store handles everything — no StoreKit code needed in the app itself. If you prefer a free download with a premium unlock, use StoreKit 2's Product.purchase() API to sell a non-consumable IAP called something like com.yourapp.level.pro; gating features such as a digital angle display, an audio beep when level, or a landscape two-axis mode behind that purchase gives users a reason to upgrade. With StoreKit 2 on iOS 17 you can check entitlements with await Transaction.currentEntitlement(for:) at app launch and gate your UI accordingly — no receipt validation server required.

Shipping this faster with Soarias

Soarias scaffolds the entire project — MotionManager, BubbleView, ContentView, a pre-filled PrivacyInfo.xcprivacy, and a working fastlane Fastfile — from a single prompt. It also auto-populates your App Store Connect metadata (app name, subtitle, keyword field, support URL) and generates the four required screenshots at 6.9-inch, 6.5-inch, and 12.9-inch iPad sizes using a local renderer, so you never touch Simulator screenshot mode manually.

For a beginner-complexity app like this one, the manual path from empty Xcode project to approved App Store listing typically takes two weekends: one to build and one to fight App Store metadata and screenshot requirements. With Soarias that second weekend compresses to an afternoon — the scaffolding, Privacy Manifest, fastlane lane, and ASC submission are handled in one automated flow, leaving you to focus only on the look and feel of the bubble itself.

Related guides

FAQ

Does this work on iOS 16?

The CoreMotion and basic SwiftUI parts work on iOS 16. Two specific APIs used here require iOS 17: the @Observable macro (from the Observation framework) and the .sensoryFeedback()@Observable for ObservableObject + @Published and remove the haptic modifier to support iOS 16, but targeting iOS 17+ is strongly recommended for new App Store submissions in 2026 — Apple's own analytics show well over 95% adoption.

Do I need a paid Apple Developer account to test?

You can install the app on your own device with a free Apple ID using Xcode's automatic signing — no $99/year membership required just to run on your phone. However, a free account certificate expires after seven days, meaning you must re-install weekly. You need the paid Developer Program membership to distribute via TestFlight or submit to the App Store.

How do I add this to the App Store?

In App Store Connect create a new iOS app record, fill in the required metadata (name, subtitle, description, keywords, support URL, privacy policy URL), upload at least one screenshot per required device size, set a price, and submit a build from Xcode via Product › Archive › Distribute App › App Store Connect. The review queue typically takes 24–48 hours for a new app. Soarias automates this entire flow — screenshots, metadata upload, and submission — from the command line.

Can I add a landscape two-axis reading — like a circular and a flat bar level in one app?

Yes, and it's a natural v1.1 feature. For a flat bar level (the kind you'd use against a wall), you only care about gravity.x when the device is held portrait. Use GeometryReader or observe UIDevice.current.orientation changes to decide which axis to emphasise and switch between a circular BubbleView and a horizontal bar view accordingly. Gate the second view mode behind your one-time IAP purchase to give the free version a compelling upgrade reason.

Last reviewed: 2026-05-12 by the Soarias team.

```