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

How to Build a Ruler App in SwiftUI

A Ruler app turns an iPhone screen into a portable measuring tool by mapping on-screen drag distance to real-world centimetres and inches. It's ideal for indie developers who want a focused, single-purpose utility that's quick to build and easy to monetise.

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

Prerequisites

Architecture overview

The app is intentionally thin: a RulerViewModel (marked @Observable) holds the current measurement, selected unit, and device PPI. A single RulerCanvasView renders tick marks via SwiftUI's Canvas API, and a DragGesture updates the endpoint in real time. CoreMotion supplies the device-tilt angle so measurements stay accurate when the phone isn't perfectly flat on a surface. Persistence is minimal — SwiftData stores a calibration offset and the last-used unit preference. There are no network calls and no third-party dependencies.

RulerApp/
├── RulerApp.swift          ← @main entry point
├── Model/
│   └── Measurement.swift   ← SwiftData @Model for saved calibration
├── ViewModel/
│   └── RulerViewModel.swift ← @Observable, CoreMotion, PPI logic
├── Views/
│   ├── ContentView.swift   ← Root: toolbar + canvas
│   ├── RulerCanvasView.swift ← Canvas tick-mark drawing + DragGesture
│   └── UnitPickerView.swift ← cm / in segmented control
├── Utilities/
│   └── DevicePPI.swift     ← Static PPI lookup by UIDevice model
└── PrivacyInfo.xcprivacy   ← Required API declarations

Step-by-step

1. Create the Xcode project

Open Xcode 16, choose File › New › Project, pick the iOS App template, set Interface to SwiftUI and Storage to SwiftData. Give it a bundle ID you own (e.g. com.yourname.ruler) — you'll need this to match your App Store Connect record later.

// RulerApp.swift
import SwiftUI
import SwiftData

@main
struct RulerApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: CalibrationRecord.self)
    }
}

2. Define the measurement data model

Use a lightweight SwiftData model to persist the user's calibration offset and preferred unit between launches. The calibration offset lets users fine-tune the reading against a known reference object.

// Model/Measurement.swift
import SwiftData
import Foundation

enum MeasurementUnit: String, Codable, CaseIterable {
    case centimeters = "cm"
    case inches      = "in"
}

@Model
final class CalibrationRecord {
    var offsetPoints: Double        // positive = screen reads longer than reality
    var preferredUnit: MeasurementUnit

    init(offsetPoints: Double = 0, preferredUnit: MeasurementUnit = .centimeters) {
        self.offsetPoints   = offsetPoints
        self.preferredUnit  = preferredUnit
    }
}

// Utilities/DevicePPI.swift
import UIKit

enum DevicePPI {
    /// Returns logical points-per-inch for the current device model.
    /// Falls back to 163 (iPhone SE baseline) if unrecognised.
    static var current: Double {
        // UIScreen.main is deprecated in iOS 16+ scenes; use the first connected scene window instead.
        let scale = UIScreen.main.scale
        // Native logical resolution; 1 pt = 1/163 inch on non-Retina, but all modern iPhones are 326 or 460 ppi.
        // We store logical PPI (physical / scale) because SwiftUI layout works in points.
        let physicalPPI: Double = {
            var systemInfo = utsname()
            uname(&systemInfo)
            let machine = withUnsafePointer(to: &systemInfo.machine) {
                $0.withMemoryRebound(to: CChar.self, capacity: 1) { String(cString: $0) }
            }
            switch machine {
            case _ where machine.hasPrefix("iPhone16"):  return 460  // Pro models
            case _ where machine.hasPrefix("iPhone15"):  return 460
            case _ where machine.hasPrefix("iPhone14"):  return 460
            case _ where machine.hasPrefix("iPhone13"):  return 460
            case _ where machine.hasPrefix("iPhone12"):  return 460
            case _ where machine.hasPrefix("iPhone11"):  return 326
            default:                                      return 326
            }
        }()
        return physicalPPI / scale
    }
}

3. Build the ruler canvas view

Draw the ruler face with SwiftUI's Canvas for smooth, resolution-independent rendering. Tick marks are drawn at regular point intervals derived from the device PPI, with longer marks every centimetre (or half-inch).

// Views/RulerCanvasView.swift
import SwiftUI

struct RulerCanvasView: View {
    let ppiPoints: Double                 // logical points per inch
    let unit: MeasurementUnit
    let endX: Double                      // current drag endpoint in points
    let onDrag: (Double) -> Void

    private var majorStep: Double {       // points between labelled ticks
        unit == .centimeters ? ppiPoints / 2.54 : ppiPoints
    }
    private var minorStep: Double {       // points between small ticks
        majorStep / 10
    }

    var body: some View {
        Canvas { ctx, size in
            // Background
            ctx.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.white))

            // Baseline
            var baseline = Path()
            baseline.move(to: CGPoint(x: 0, y: size.height * 0.15))
            baseline.addLine(to: CGPoint(x: size.width, y: size.height * 0.15))
            ctx.stroke(baseline, with: .color(.black), lineWidth: 1.5)

            // Tick marks
            var x = 0.0
            var tickIndex = 0
            while x <= size.width {
                let isMajor  = tickIndex % 10 == 0
                let isMid    = tickIndex % 5  == 0
                let tickH    = isMajor ? size.height * 0.55
                             : isMid   ? size.height * 0.38
                                       : size.height * 0.22
                var tick = Path()
                tick.move(to:    CGPoint(x: x, y: size.height * 0.15))
                tick.addLine(to: CGPoint(x: x, y: size.height * 0.15 + tickH))
                ctx.stroke(tick, with: .color(.black), lineWidth: isMajor ? 1.2 : 0.6)

                if isMajor {
                    let label = unit == .centimeters
                        ? "\(tickIndex / 10)"
                        : String(format: "%.0f", Double(tickIndex) / 10.0)
                    ctx.draw(
                        Text(label).font(.system(size: 10, weight: .medium)),
                        at: CGPoint(x: x, y: size.height * 0.15 + tickH + 6),
                        anchor: .top
                    )
                }
                x          += minorStep
                tickIndex  += 1
            }

            // Measurement highlight
            if endX > 0 {
                var highlight = Path()
                highlight.move(to:    CGPoint(x: 0,    y: size.height * 0.12))
                highlight.addLine(to: CGPoint(x: endX, y: size.height * 0.12))
                ctx.stroke(highlight, with: .color(.blue.opacity(0.5)), lineWidth: 3)

                // Endpoint handle
                ctx.fill(
                    Path(ellipseIn: CGRect(x: endX - 8, y: size.height * 0.08, width: 16, height: 16)),
                    with: .color(.blue)
                )
            }
        }
        .gesture(
            DragGesture(minimumDistance: 0)
                .onChanged { value in onDrag(max(0, value.location.x)) }
        )
    }
}

#Preview {
    RulerCanvasView(
        ppiPoints: 163,
        unit: .centimeters,
        endX: 120,
        onDrag: { _ in }
    )
    .frame(height: 120)
    .border(Color.gray.opacity(0.3))
}

4. Implement on-screen measurement with CoreMotion tilt correction

This is the core of the app. The ViewModel converts drag distance in points to real-world units, applies the calibration offset, and optionally uses CoreMotion's device-attitude pitch to correct for objects held above the screen at an angle.

// ViewModel/RulerViewModel.swift
import Observation
import CoreMotion
import Foundation

@Observable
final class RulerViewModel {
    var endX: Double        = 0        // raw drag endpoint in SwiftUI points
    var unit: MeasurementUnit = .centimeters
    var calibrationOffset: Double = 0  // points, from stored CalibrationRecord

    private(set) var tiltCorrectedValue: Double = 0
    private var pitchRadians: Double = 0

    private let motionManager = CMMotionManager()
    let ppiPoints: Double = DevicePPI.current

    init() { startMotion() }

    // MARK: – Public

    var displayValue: String {
        let corrected = correctedPoints
        let realValue: Double
        switch unit {
        case .centimeters: realValue = corrected / (ppiPoints / 2.54)
        case .inches:      realValue = corrected / ppiPoints
        }
        return String(format: "%.1f %@", max(0, realValue), unit.rawValue)
    }

    func updateDrag(to x: Double) {
        endX = x
    }

    // MARK: – Private

    private var correctedPoints: Double {
        guard pitchRadians != 0 else { return endX + calibrationOffset }
        // When the phone is tilted, the projected distance on the surface is longer.
        // Multiply by cos(pitch) to get the true flat distance.
        return (endX * cos(pitchRadians)) + calibrationOffset
    }

    private func startMotion() {
        guard motionManager.isDeviceMotionAvailable else { return }
        motionManager.deviceMotionUpdateInterval = 1.0 / 30
        motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motion, _ in
            guard let motion else { return }
            self?.pitchRadians = motion.attitude.pitch
        }
    }

    deinit { motionManager.stopDeviceMotionUpdates() }
}

5. Wire up ContentView and add the Privacy Manifest

Connect the ViewModel to the canvas, add a unit picker and a live readout label. Then create PrivacyInfo.xcprivacy — Apple now rejects submissions that call certain system APIs (including CMMotionManager) without a corresponding reason declared in this file.

// Views/ContentView.swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var records: [CalibrationRecord]

    @State private var vm = RulerViewModel()

    private var record: CalibrationRecord {
        if let existing = records.first { return existing }
        let new = CalibrationRecord()
        modelContext.insert(new)
        return new
    }

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // Live readout
                Text(vm.displayValue)
                    .font(.system(size: 52, weight: .thin, design: .monospaced))
                    .padding(.top, 32)

                Spacer()

                // Ruler canvas
                RulerCanvasView(
                    ppiPoints: vm.ppiPoints,
                    unit: vm.unit,
                    endX: vm.endX,
                    onDrag: { vm.updateDrag(to: $0) }
                )
                .frame(height: 130)
                .padding(.horizontal, 0)
                .background(Color(.systemBackground))
                .shadow(color: .black.opacity(0.06), radius: 8, y: -2)

                // Unit picker
                Picker("Unit", selection: $vm.unit) {
                    ForEach(MeasurementUnit.allCases, id: \.self) { u in
                        Text(u.rawValue).tag(u)
                    }
                }
                .pickerStyle(.segmented)
                .padding()
            }
            .navigationTitle("Ruler")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Reset") { vm.updateDrag(to: 0) }
                }
            }
        }
        .onAppear {
            vm.calibrationOffset = record.offsetPoints
            vm.unit              = record.preferredUnit
        }
        .onChange(of: vm.unit) { _, new in
            record.preferredUnit = new
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: CalibrationRecord.self, inMemory: true)
}

Add PrivacyInfo.xcprivacy to your app target (File › New File › App Privacy). Set the following key in the XML:

<?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>
      <!-- CoreMotion / CMMotionManager -->
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryMotion</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <!-- "App functionality" reason -->
        <string>C617.1</string>
      </array>
    </dict>
  </array>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyTracking</key>
  <false/>
</dict>
</plist>

Common pitfalls

Adding monetization: One-time purchase

A one-time purchase fits a utility like this perfectly — users know exactly what they're paying for, and there's no subscription fatigue. Implement it with StoreKit 2: add a non-consumable In-App Purchase product in App Store Connect (e.g. com.yourname.ruler.pro), then use Product.products(for:) and product.purchase() in your paywall view. Gate premium features — such as a second movable endpoint for measuring gaps between two objects, or an AR-overlay mode — behind a Transaction.currentEntitlement(for:) check. StoreKit 2's Transaction.updates async sequence handles restores automatically without a separate "Restore Purchases" code path (though you should still surface a restore button for App Review compliance).

Shipping this faster with Soarias

Soarias automates the parts of this project that aren't writing code: it scaffolds your SwiftUI project with SwiftData wired up, generates the PrivacyInfo.xcprivacy file with the correct CoreMotion reason code pre-filled, sets up a fastlane Fastfile with match for code signing, and submits the finished build to App Store Connect — including all required metadata fields, age ratings, and privacy nutrition labels — without you ever opening App Store Connect's web UI manually.

For a beginner-complexity app like this Ruler, Soarias typically compresses the non-coding overhead from a half-day of configuration and form-filling down to a few minutes. You stay in your editor; Soarias handles the App Store bureaucracy. The time you reclaim on a 1–2 weekend project is meaningful — it's often the difference between shipping on Sunday afternoon and shipping the following weekend.

Related guides

FAQ

Does this work on iOS 16?

The SwiftUI Canvas API and DragGesture work on iOS 15+, but this guide targets iOS 17+ because it uses the #Preview macro and the @Observable macro from the Observation framework — both require iOS 17. You can back-deploy to iOS 16 by replacing @Observable with ObservableObject and @Published, and swapping #Preview for PreviewProvider, but it's extra maintenance work for a shrinking user base.

Do I need a paid Apple Developer account to test?

No — you can run the app on your own physical device with a free Apple ID by signing the app with a personal team in Xcode. However, CoreMotion works fine with a free account. You only need the paid $99/year Apple Developer Program membership when you want to distribute via TestFlight or submit to the App Store.

How do I add this to the App Store?

Create an app record in App Store Connect, set your bundle ID, upload a build from Xcode (Product › Archive), then fill in the required metadata: screenshots for all required device sizes, a privacy policy URL, age rating questionnaire, and privacy nutrition labels. If you've added an In-App Purchase, attach it to the version before submitting. Expect 24–48 hours for initial review.

How accurate can an on-screen ruler be, really?

With a correct PPI value and a flat surface, you can get within 1–2 mm on modern iPhones — good enough for most everyday tasks. The biggest sources of error are (1) incorrect PPI for your device model, (2) measuring a 3D object held above the screen rather than resting on it (the tilt-correction feature helps here), and (3) parallax error from viewing the tick marks at an angle. For professional or safety-critical measurements, always verify with a physical ruler.

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

```