Skip to content

Unit Conversion in Swift: Locale-Aware Metric and Imperial with Thread-Safe Persistence

Introduction

Your app stores a run as 5,000 meters. A user in Berlin sees "5.0 km". A user in Texas sees "3.1 mi". Same data, different presentation—and if you get it wrong, your fitness app looks broken.

Apple provides Measurement and MeasurementFormatter for rich unit handling, but when your app only needs two measurement types (distance and weight) with a user-toggleable preference that syncs to widgets, those APIs are heavier than necessary. A String-backed enum with a few conversion methods gives you everything you need in under 100 lines.

This article builds a production-tested UnitSystem that:

  • Auto-detects metric or imperial from the device's region
  • Converts between storage units (meters, kilograms) and display units (km/mi, kg/lbs)
  • Persists the user's choice via App Group UserDefaults with a thread-safe first-access pattern
  • Plugs directly into SwiftUI with @AppStorage and a segmented picker

Prerequisites

  • Swift 5.9+ / Xcode 15+
  • An App Group configured in your target's entitlements (for widget sync)

Defining the UnitSystem Enum

The core type is a String-backed enum. Using String as the raw value means Codable conformance is automatic—no custom encoding needed—and UserDefaults can store it directly:

swift
import Foundation

public enum UnitSystem: String, Codable, CaseIterable, Sendable {
    case metric   // km, kg
    case imperial // miles, lbs

    public var displayName: String {
        switch self {
        case .metric:   "Metric (km, kg)"
        case .imperial: "Imperial (mi, lbs)"
        }
    }
}

Three conformances are worth noting:

  • CaseIterable — provides UnitSystem.allCases for building pickers without hardcoding options
  • Sendable — marks the type as safe to pass across concurrency boundaries (required for Swift 6 strict concurrency)
  • Codable — free JSON encoding/decoding from the String raw value

Distance Conversions

Every distance is stored in meters. The enum converts to and from the user's display unit:

swift
extension UnitSystem {
    public var distanceUnit: String {
        switch self {
        case .metric:   "km"
        case .imperial: "mi"
        }
    }

    public var distanceUnitLong: String {
        switch self {
        case .metric:   "kilometers"
        case .imperial: "miles"
        }
    }

    /// Convert meters to display unit (km or miles)
    public func displayDistance(meters: Double) -> Double {
        switch self {
        case .metric:   meters / 1_000.0
        case .imperial: meters / 1_609.344
        }
    }

    /// Convert display unit back to meters for storage
    public func metersFromDisplay(_ value: Double) -> Double {
        switch self {
        case .metric:   value * 1_000.0
        case .imperial: value * 1_609.344
        }
    }
}

The conversion factor 1,609.344 is the exact number of meters in one international mile. Using a single constant for both directions (multiply going in, divide coming out) eliminates drift between the forward and inverse conversions.

Weight Conversions

Same pattern, with kilograms as the storage unit:

swift
extension UnitSystem {
    public var weightUnit: String {
        switch self {
        case .metric:   "kg"
        case .imperial: "lbs"
        }
    }

    public var weightUnitLong: String {
        switch self {
        case .metric:   "kilograms"
        case .imperial: "pounds"
        }
    }

    /// Convert kilograms to display unit (kg or lbs)
    public func displayWeight(kilograms: Double) -> Double {
        switch self {
        case .metric:   kilograms
        case .imperial: kilograms * 2.20462
        }
    }

    /// Convert display unit back to kilograms for storage
    public func kilogramsFromDisplay(_ value: Double) -> Double {
        switch self {
        case .metric:   value
        case .imperial: value / 2.20462
        }
    }
}

TIP

The metric case returns the value unchanged—no math at all. This is a subtle benefit of storing in metric: the most common unit system worldwide incurs zero conversion cost.

Locale Detection

Only three countries officially use the imperial system: the United States, Liberia, and Myanmar. Everyone else uses metric. The detection is straightforward:

swift
extension UnitSystem {
    /// Detect preferred unit system from the device's region.
    public static func fromLocale() -> UnitSystem {
        let regionCode = Locale.current.region?.identifier ?? ""
        let imperialRegions = ["US", "LR", "MM"]
        return imperialRegions.contains(regionCode) ? .imperial : .metric
    }
}

Locale.current.region?.identifier returns the ISO 3166-1 alpha-2 code for the user's region setting. This is more reliable than checking Locale.current.usesMetricSystem in cases where you want per-app control rather than deferring to the system formatter.

Important

Locale.current reflects the device settings at the time of access, not at app launch. If a user changes their region in Settings and returns to your app, fromLocale() will pick up the new region. This is usually desirable, but be aware it can change mid-session.

Thread-Safe Persistence

The preference is stored in App Group UserDefaults so it syncs between the main app and widgets. The current static property implements a compare-and-set pattern for safe first access:

swift
public extension UnitSystem {
    static var current: UnitSystem {
        get {
            // Fast path: already stored
            if let rawValue = SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system),
               let system = UnitSystem(rawValue: rawValue)
            {
                return system
            }

            // First access: detect from locale
            let detected = fromLocale()

            // Only persist if not already set by concurrent access
            if SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system) == nil
            {
                SharedDefaults.appGroup.set(
                    detected.rawValue,
                    forKey: SharedDefaults.Keys.Units.system
                )
            }

            // Return the stored value (may have been set by another thread)
            if let storedRaw = SharedDefaults.appGroup
                .string(forKey: SharedDefaults.Keys.Units.system),
               let stored = UnitSystem(rawValue: storedRaw)
            {
                return stored
            }

            return detected
        }
        set {
            SharedDefaults.appGroup.set(
                newValue.rawValue,
                forKey: SharedDefaults.Keys.Units.system
            )
        }
    }
}

Here's why there are three reads:

  1. First read — the fast path. If the key already exists (99% of accesses after first launch), return immediately.
  2. Detect + conditional write — on first access, detect from locale and persist only if the key is still nil. This prevents overwriting a value that a concurrent thread may have just set.
  3. Final read — re-read the stored value to guarantee we return whatever was actually persisted, not just what we detected. If another thread won the race, we use their value.

This three-read pattern avoids locks while ensuring all threads converge on the same value after first access.

TIP

UserDefaults is thread-safe for individual read/write operations. The compare-and-set here isn't about protecting UserDefaults itself—it's about ensuring the locale-detected default is only written once, even when multiple threads trigger first access simultaneously.

SharedDefaults Setup

The SharedDefaults wrapper provides a centralized access point for App Group UserDefaults:

swift
public enum SharedDefaults {
    public static var appGroup: UserDefaults {
        UserDefaults(suiteName: "group.com.yourapp") ?? .standard
    }

    public enum Keys {
        public enum Units {
            public static let system = "dev.yourapp.units.system"
        }
    }
}

If the App Group isn't accessible (e.g., running in a test target without entitlements), it falls back to UserDefaults.standard. This prevents crashes while clearly degrading: the preference won't sync to widgets, but the app still works.

SwiftUI Integration

Settings Picker

@AppStorage reads directly from the same UserDefaults key, so changes in the picker propagate instantly to every view reading UnitSystem.current:

swift
import SwiftUI

struct AppPreferencesView: View {
    @AppStorage(
        SharedDefaults.Keys.Units.system,
        store: SharedDefaults.appGroup
    )
    private var unitSystemRaw = UnitSystem.fromLocale().rawValue

    private var unitSystemBinding: Binding<UnitSystem> {
        Binding(
            get: { UnitSystem(rawValue: unitSystemRaw) ?? .metric },
            set: { unitSystemRaw = $0.rawValue }
        )
    }

    var body: some View {
        Picker("Unit System", selection: unitSystemBinding) {
            ForEach(UnitSystem.allCases, id: \.self) { system in
                Text(system.displayName).tag(system)
            }
        }
        .pickerStyle(.segmented)
    }
}

The Binding wrapper is necessary because @AppStorage stores the raw String, not the UnitSystem enum directly. The binding translates between the two.

Displaying Converted Values

At the view layer, always convert from storage units (metric) to display units:

swift
func formatDistance(_ meters: Double) -> String {
    let system = UnitSystem.current
    let value = system.displayDistance(meters: meters)
    return String(format: "%.1f %@", value, system.distanceUnit)
}

func formatWeight(_ kilograms: Double) -> String {
    let system = UnitSystem.current
    let value = system.displayWeight(kilograms: kilograms)
    return String(format: "%.0f %@", value, system.weightUnit)
}

// Usage
formatDistance(5000)    // "5.0 km" or "3.1 mi"
formatWeight(90)       // "90 kg" or "198 lbs"

Accepting User Input

When the user types a value in their preferred unit, convert it back to metric before saving:

swift
func saveExercise(distanceText: String, weightText: String) {
    let system = UnitSystem.current

    if let displayDistance = Double(distanceText), displayDistance > 0 {
        let meters = system.metersFromDisplay(displayDistance)
        // Store `meters` in your database
    }

    if let displayWeight = Double(weightText), displayWeight >= 0 {
        let kilograms = system.kilogramsFromDisplay(displayWeight)
        // Store `kilograms` in your database
    }
}

The Store-in-Metric Pattern

The design principle behind this utility is worth calling out explicitly:

Always store values in metric. Convert at the display layer only.

This means:

  • Database values are unit-agnostic. A distance of 5000.0 always means 5,000 meters, regardless of what the user's preference was when they entered it.
  • Changing preferences doesn't require data migration. If a user switches from imperial to metric, their data doesn't need to be recalculated—only the display changes.
  • Aggregations work correctly. Summing distances from different users (or from the same user who changed their preference) gives correct results because everything is in the same unit.
  • Round-trip accuracy is preserved. Converting display → meters → display using the same factor produces the original value (within floating-point precision).

Important

If you store values in the user's display unit, you create a dependency between the data and the preference. Changing the preference would require recalculating every stored value—a migration nightmare for any dataset of non-trivial size.

Testing

The enum's simplicity makes testing straightforward. Cover conversions, round trips, and edge cases:

swift
import XCTest

final class UnitSystemTests: XCTestCase {

    // MARK: - Distance

    func testMetricDistanceConversion() {
        let system = UnitSystem.metric
        XCTAssertEqual(system.displayDistance(meters: 1_000), 1.0)
        XCTAssertEqual(system.displayDistance(meters: 5_000), 5.0)
    }

    func testImperialDistanceConversion() {
        let system = UnitSystem.imperial
        let miles = system.displayDistance(meters: 1_609.344)
        XCTAssertEqual(miles, 1.0, accuracy: 0.0001)
    }

    func testDistanceRoundTrip() {
        for system in UnitSystem.allCases {
            let original = 42_195.0 // Marathon in meters
            let display = system.displayDistance(meters: original)
            let backToMeters = system.metersFromDisplay(display)
            XCTAssertEqual(backToMeters, original, accuracy: 0.001)
        }
    }

    // MARK: - Weight

    func testImperialWeightConversion() {
        let system = UnitSystem.imperial
        let lbs = system.displayWeight(kilograms: 1.0)
        XCTAssertEqual(lbs, 2.20462, accuracy: 0.0001)
    }

    func testWeightRoundTrip() {
        for system in UnitSystem.allCases {
            let original = 100.0 // kg
            let display = system.displayWeight(kilograms: original)
            let backToKg = system.kilogramsFromDisplay(display)
            XCTAssertEqual(backToKg, original, accuracy: 0.001)
        }
    }

    // MARK: - Edge Cases

    func testZeroValues() {
        for system in UnitSystem.allCases {
            XCTAssertEqual(system.displayDistance(meters: 0), 0)
            XCTAssertEqual(system.displayWeight(kilograms: 0), 0)
        }
    }

    // MARK: - Codable

    func testCodableRoundTrip() throws {
        let original = UnitSystem.imperial
        let data = try JSONEncoder().encode(original)
        let decoded = try JSONDecoder().decode(UnitSystem.self, from: data)
        XCTAssertEqual(decoded, original)
    }

    // MARK: - Labels

    func testUnitLabels() {
        XCTAssertEqual(UnitSystem.metric.distanceUnit, "km")
        XCTAssertEqual(UnitSystem.imperial.distanceUnit, "mi")
        XCTAssertEqual(UnitSystem.metric.weightUnit, "kg")
        XCTAssertEqual(UnitSystem.imperial.weightUnit, "lbs")
    }
}

TIP

The round-trip tests use accuracy because floating-point division followed by multiplication can introduce tiny errors. An accuracy of 0.001 (one thousandth of a meter or kilogram) is more than sufficient for any real-world measurement.

When to Use Apple's Measurement Framework Instead

This lightweight approach works well when:

  • You have a fixed set of measurement types (distance + weight, in this case)
  • You want user-toggleable preference stored in UserDefaults
  • You need widget sync via App Groups
  • You prefer explicit control over formatting

Consider Apple's Measurement<UnitLength> and MeasurementFormatter when:

  • You handle many unit types (temperature, speed, volume, pressure, etc.)
  • You need automatic locale-based formatting without building your own format strings
  • You want natural scale conversion (e.g., automatically showing "1.2 km" instead of "1,200 m")

Both approaches follow the same principle: store in a canonical unit, convert at display time.

Conclusion

A String-backed enum gives you a complete unit conversion system in under 100 lines:

  • Locale detection assigns the right default on first launch by checking three region codes
  • Bidirectional conversion methods keep the API symmetric—displayDistance and metersFromDisplay use the same factor
  • Thread-safe persistence through a compare-and-set pattern ensures consistent defaults without locks
  • SwiftUI integration via @AppStorage and CaseIterable makes the settings UI trivial

The key architectural decision is the store-in-metric pattern. By keeping your database unit-agnostic, you decouple data from presentation and avoid migration headaches when users change their preference.