How to Build a Unit Converter App in SwiftUI
A Unit Converter app lets users instantly convert between length, weight, and temperature units — the kind of utility people reach for daily when cooking, traveling, or working across measurement systems. This guide is for Swift beginners who want a complete, shippable iOS app to publish on the App Store.
Prerequisites
- Mac running macOS Sequoia or later with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift and SwiftUI knowledge (variables, structs, @State)
- No special entitlements needed — this app runs fully in the Simulator
Architecture overview
The app is intentionally thin: a single @Observable view model holds the selected category, input value, and chosen units, delegating all math to Foundation's Measurement and UnitConverter types. There are no network calls, no persistence layer, and no external dependencies — just a Form-based UI that reacts to state changes. This makes it a perfect first App Store project: fast to build, easy to maintain, and trivial to extend with new unit categories later.
UnitConverterApp/ ├── App/ │ └── UnitConverterApp.swift # @main entry point ├── Model/ │ ├── UnitCategory.swift # Length / Weight / Temperature enum │ └── ConverterViewModel.swift # @Observable state + conversion logic ├── Views/ │ ├── ContentView.swift # Tab or category picker shell │ ├── ConverterFormView.swift # Core Form UI │ └── CategoryPickerView.swift # Segmented category selector ├── Resources/ │ └── PrivacyInfo.xcprivacy # Required App Store manifest └── UnitConverterApp.xcodeproj
Step-by-step
1. Create the Xcode project
Open Xcode 16, choose File → New → Project, pick the iOS App template, and set Interface to SwiftUI and Storage to None. Give it a reverse-DNS bundle ID like com.yourname.unitconverter. Delete the default ContentView boilerplate — you'll replace it in step 3.
// UnitConverterApp.swift
import SwiftUI
@main
struct UnitConverterApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
2. Define the data model
Foundation ships UnitLength, UnitMass, and UnitTemperature out of the box. Wrap them in a UnitCategory enum so the UI can drive everything from a single picker. Each case vends a typed array of available units.
// UnitCategory.swift
import Foundation
enum UnitCategory: String, CaseIterable, Identifiable {
case length = "Length"
case weight = "Weight"
case temperature = "Temperature"
var id: String { rawValue }
var lengthUnits: [UnitLength] {
[.meters, .kilometers, .feet, .yards, .miles, .inches, .centimeters]
}
var massUnits: [UnitMass] {
[.kilograms, .grams, .pounds, .ounces, .metricTons]
}
var temperatureUnits: [UnitTemperature] {
[.celsius, .fahrenheit, .kelvin]
}
}
extension Dimension: @retroactive Identifiable {
public var id: ObjectIdentifier { ObjectIdentifier(self) }
}
extension UnitLength {
var label: String {
switch self {
case .meters: return "Meters (m)"
case .kilometers: return "Kilometers (km)"
case .feet: return "Feet (ft)"
case .yards: return "Yards (yd)"
case .miles: return "Miles (mi)"
case .inches: return "Inches (in)"
case .centimeters: return "Centimeters (cm)"
default: return symbol
}
}
}
extension UnitMass {
var label: String {
switch self {
case .kilograms: return "Kilograms (kg)"
case .grams: return "Grams (g)"
case .pounds: return "Pounds (lb)"
case .ounces: return "Ounces (oz)"
case .metricTons: return "Metric Tons (t)"
default: return symbol
}
}
}
extension UnitTemperature {
var label: String {
switch self {
case .celsius: return "Celsius (°C)"
case .fahrenheit: return "Fahrenheit (°F)"
case .kelvin: return "Kelvin (K)"
default: return symbol
}
}
}
3. Build the view model
An @Observable class (Swift 5.9+ / iOS 17) keeps the selected category, raw input string, and chosen from/to units. Conversion is a simple computed property that delegates to Measurement.
// ConverterViewModel.swift
import Foundation
import Observation
@Observable
final class ConverterViewModel {
var category: UnitCategory = .length
var inputText: String = ""
// Length
var fromLength: UnitLength = .meters
var toLength: UnitLength = .feet
// Weight
var fromMass: UnitMass = .kilograms
var toMass: UnitMass = .pounds
// Temperature
var fromTemp: UnitTemperature = .celsius
var toTemp: UnitTemperature = .fahrenheit
var inputValue: Double { Double(inputText) ?? 0 }
var result: String {
let formatted = NumberFormatter()
formatted.maximumFractionDigits = 6
formatted.minimumFractionDigits = 0
switch category {
case .length:
let m = Measurement(value: inputValue, unit: fromLength)
let converted = m.converted(to: toLength)
return (formatted.string(from: converted.value as NSNumber) ?? "—") + " \(toLength.symbol)"
case .weight:
let m = Measurement(value: inputValue, unit: fromMass)
let converted = m.converted(to: toMass)
return (formatted.string(from: converted.value as NSNumber) ?? "—") + " \(toMass.symbol)"
case .temperature:
let m = Measurement(value: inputValue, unit: fromTemp)
let converted = m.converted(to: toTemp)
return (formatted.string(from: converted.value as NSNumber) ?? "—") + " \(toTemp.symbol)"
}
}
}
4. Build the Form UI
SwiftUI's Form gives you a native grouped-table layout for free. A Picker with .segmented style drives the category; two more Picker views handle from/to units. The result updates instantly as the user types.
// ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var vm = ConverterViewModel()
var body: some View {
NavigationStack {
Form {
// Category picker
Section {
Picker("Category", selection: $vm.category) {
ForEach(UnitCategory.allCases) { cat in
Text(cat.rawValue).tag(cat)
}
}
.pickerStyle(.segmented)
}
// Input
Section("Input") {
TextField("Enter value", text: $vm.inputText)
.keyboardType(.decimalPad)
}
// From / To pickers
Section("Convert") {
unitFromPicker
unitToPicker
}
// Result
Section("Result") {
Text(vm.result)
.font(.title2.monospacedDigit())
.foregroundStyle(.primary)
.textSelection(.enabled)
}
}
.navigationTitle("Unit Converter")
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Done") {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
}
}
}
}
@ViewBuilder
private var unitFromPicker: some View {
switch vm.category {
case .length:
Picker("From", selection: $vm.fromLength) {
ForEach(vm.category.lengthUnits, id: \.self) { u in
Text(u.label).tag(u)
}
}
case .weight:
Picker("From", selection: $vm.fromMass) {
ForEach(vm.category.massUnits, id: \.self) { u in
Text(u.label).tag(u)
}
}
case .temperature:
Picker("From", selection: $vm.fromTemp) {
ForEach(vm.category.temperatureUnits, id: \.self) { u in
Text(u.label).tag(u)
}
}
}
}
@ViewBuilder
private var unitToPicker: some View {
switch vm.category {
case .length:
Picker("To", selection: $vm.toLength) {
ForEach(vm.category.lengthUnits, id: \.self) { u in
Text(u.label).tag(u)
}
}
case .weight:
Picker("To", selection: $vm.toMass) {
ForEach(vm.category.massUnits, id: \.self) { u in
Text(u.label).tag(u)
}
}
case .temperature:
Picker("To", selection: $vm.toTemp) {
ForEach(vm.category.temperatureUnits, id: \.self) { u in
Text(u.label).tag(u)
}
}
}
}
}
#Preview {
ContentView()
}
5. Add the Privacy Manifest (required for App Store)
Since iOS 17, Apple requires a PrivacyInfo.xcprivacy file in every new app submission. A Unit Converter collects no user data, so the manifest is minimal — but omitting it causes App Store Connect to reject the build automatically.
<!-- PrivacyInfo.xcprivacy -->
<?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>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
Add this file to your project via File → New → File from Template → App Privacy. If you later add an ad SDK (see Monetization below), you will need to update NSPrivacyTrackingDomains to list the ad network's domains.
Common pitfalls
- Using @ObservableObject instead of @Observable. On iOS 17+, the new
@Observablemacro replacesObservableObject/@Published. Mixing both on the same class causes duplicate observation overhead and compiler warnings. Pick one. - Forgetting to update PrivacyInfo.xcprivacy when adding ad SDKs. Google AdMob and other ad networks make API calls that Apple considers "tracking." Submitting without listing their domains in your manifest triggers an automatic App Store review rejection.
- Displaying too many decimal places. Foundation's default
Measurementdescription renders 15+ digits for most conversions. Always format output with aNumberFormattercapped at a sensible fraction digit count (6 is a good default). - Hardcoding unit lists. Foundation's
UnitLengthetc. have a static.baseUnit()but no built-inallCases. Manually curate the list you expose in the UI, or users will be confused by obscure units like.furlongsappearing in production. - Not handling the "From equals To" edge case. If a user selects the same unit for both From and To, your result should just echo the input. Foundation handles this correctly, but it's worth a UI test to confirm the result label doesn't show floating-point noise like
1.0000000000000002 m.
Adding monetization: Ad-supported
The most common approach for free utility apps is a banner ad at the bottom of the screen using Google AdMob (GoogleMobileAds SDK). Add the Swift package via Xcode's package manager (https://github.com/googleads/swift-package-manager-google-mobile-ads), create a BannerAdView UIViewRepresentable that wraps GADBannerView, and embed it in a VStack below your NavigationStack. Register your app's AdMob App ID in Info.plist under GADApplicationIdentifier. Critically, update your PrivacyInfo.xcprivacy to declare NSPrivacyTracking: true and list AdMob's domains — skipping this step is the single most common cause of App Store rejection for ad-supported apps. Consider offering a one-time StoreKit in-app purchase to remove ads; even a $0.99 "Remove Ads" IAP converts well on utility apps.
Shipping this faster with Soarias
Soarias automates the parts of iOS shipping that eat time even on a simple beginner project: it scaffolds the full Xcode project structure (including a correctly filled PrivacyInfo.xcprivacy), sets up fastlane lanes for TestFlight and App Store submission, generates App Store screenshots in every required device size, and handles App Store Connect metadata like age ratings, categories, and review notes. For an ad-supported app, Soarias also prompts you to declare the correct tracking domains before you ever open App Store Connect.
For a beginner-complexity app like this one, the manual path — project setup, provisioning, fastlane config, screenshot generation, ASC form-filling — typically takes 3–5 hours spread across a weekend. With Soarias, that overhead compresses to under 30 minutes, letting you spend the weekend on the actual SwiftUI code rather than Apple's configuration maze.
Related guides
FAQ
Does this work on iOS 16?
The @Observable macro requires iOS 17+. If you need iOS 16 support, replace @Observable with ObservableObject and annotate each mutable property with @Published. The rest of the code — Measurement, Form, Picker — is compatible back to iOS 15. That said, iOS 16 represents a small and shrinking portion of active devices; targeting iOS 17+ is the recommended starting point for new apps in 2026.
Do I need a paid Apple Developer account to test?
No — you can run the app on the Simulator and on a personal device for free using Xcode's automatic signing with a free Apple ID. The $99/year Apple Developer Program is only required when you want to distribute via TestFlight or submit to the App Store. For a Unit Converter with no special entitlements, the Simulator covers all development and testing needs.
How do I add this to the App Store?
Enroll in the Apple Developer Program, create an App Store Connect record for your app, archive your build in Xcode (Product → Archive), and upload it via the Organizer. You'll then fill in metadata (name, description, screenshots, age rating) in App Store Connect and submit for review. Apple typically reviews new apps within 24–48 hours. Soarias automates the archive, upload, screenshot generation, and metadata steps if you want to skip the manual process.
What's the easiest way to add more unit categories later?
Add a new case to the UnitCategory enum (e.g., .speed, .area, .volume), return the relevant Foundation unit array from a new computed property, and extend the @ViewBuilder switches in ContentView with a matching case. Foundation covers speed (UnitSpeed), area (UnitArea), volume (UnitVolume), pressure, energy, and more — no third-party libraries needed.
Last reviewed: 2026-05-11 by the Soarias team.