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.
Prerequisites
- Mac with Xcode 16 or later
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift/SwiftUI knowledge (structs, @State, gestures)
- A physical iPhone for accurate PPI calibration — the Simulator's pixel density does not match any real device
- No special entitlements needed; CoreMotion is used without a special capability in the project file
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
- Testing on Simulator gives wrong readings. The Simulator has no fixed physical PPI — all your measurements will be garbage. Always test on a real device from the start and calibrate against a known physical ruler.
- Hardcoding PPI from old device lists. New iPhone models (e.g. the ultra-dense Pro Display models) have different PPI figures. Use a safe fallback rather than a crash, and update your lookup table each autumn when Apple ships new hardware.
- Missing the Privacy Manifest causes automatic App Store rejection. Since iOS 17.4+ enforcement, any binary that calls
CMMotionManagerwithout aPrivacyInfo.xcprivacyentry will be rejected in upload — not just review. Add it before your first TestFlight build. - Forgetting
NSMotionUsageDescriptionin Info.plist. CoreMotion still requires this human-readable key even though the Privacy Manifest handles the API-level declaration. Users will see it on first launch. - Claiming to be "accurate to the millimetre" in App Store metadata. App Review will flag this as a misleading performance claim unless you can demonstrate it. Use language like "approximate measurement" in your screenshots and description.
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.