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
UserDefaultswith a thread-safe first-access pattern - Plugs directly into SwiftUI with
@AppStorageand 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:
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— providesUnitSystem.allCasesfor building pickers without hardcoding optionsSendable— marks the type as safe to pass across concurrency boundaries (required for Swift 6 strict concurrency)Codable— free JSON encoding/decoding from theStringraw value
Distance Conversions
Every distance is stored in meters. The enum converts to and from the user's display unit:
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:
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:
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:
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:
- First read — the fast path. If the key already exists (99% of accesses after first launch), return immediately.
- 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.
- 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:
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:
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:
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:
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.0always 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:
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—
displayDistanceandmetersFromDisplayuse the same factor - Thread-safe persistence through a compare-and-set pattern ensures consistent defaults without locks
- SwiftUI integration via
@AppStorageandCaseIterablemakes 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.

