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

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.

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

Prerequisites

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

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.

```