Search code examples
swiftuiswiftdata

Measurement and localisation in SwiftUI Text and TextField views


Using Measurement with a Text view is simple enough but when it comes to TextField I’m a bit confused about what to expect, or what’s good practice for internationalisation and localisation.

The simulator App Region is set to United States so my first Text view will correctly show 2 in.

The Text view I use length.value to access the Double component of the measurement and this correctly shows 5.0.

Now we get to the TextField. Because I’ve used $length.value for the binding it will also display 5.0. I used .centimetres in my length property because that’s my region but what if someone is using a US region?

How do I make the Double localised?

import SwiftUI
struct SomeView: View {
    
    @State private var length: Measurement<UnitLength> = Measurement(value: 5, unit: .centimeters)

    var body: some View {
        Group {
            Text(length.formatted())
            Text(String(length.value))
            TextField("length", value: $length.value, format: .number)
        }
        .font(.system(size: 82))
    }
}

Edit: I did look at this question How to bind Measurement with UI in SwiftUI? but a. it doesn't address the localisation, and b. it's over 3 years old and seems a bit outdated.

I also checked out this answer on Hackingwithswift https://www.hackingwithswift.com/forums/swiftui/i-guess-this-code-is-silly-but-how-to-make-it-smarter-bindings-unitconversion/12512/12537

Update

So after re-thinking my question a little, this is what Ive come up with. This includes my SwiftData Model. This works ok but I'm unsure how I should enable the user to change the unit e.g. using a Picker.

I tried adding a unitType var to my model and using a switch statement for Locale.current.measurementSystem like @Joakim Danielson's answer below but that had issues with unitType being a get only property or something.. I'm not sure on the Picker with the item.length.unit as the selection binding and the ForEach with the item.length.unit.symbol.

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]
    
    var body: some View {
        NavigationSplitView {
            List {
                ForEach(items, id:\.self) { item in
                    NavigationLink {
                        HStack {
                            TextField("Length", value: Binding(
                                get: {
                                    item.length?.value ?? 0
                                },
                                set: {
                                    item.length = Measurement(value: $0, unit: .meters)
                                }), format: .number)
                            Text(item.length?.unit.symbol ?? "")
                        }
                        .font(.system(size: 82))
                    } label: {
                        Text(item.length ?? Measurement<UnitLength>(value: 0.0, unit: .meters), format: .measurement(width: .abbreviated, numberFormatStyle: .number))
                    }
                }
                .onDelete(perform: deleteItems)
            }
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    EditButton()
                }
                ToolbarItem {
                    Button(action: addItem) {
                        Label("Add Item", systemImage: "plus")
                    }
                }
            }
        } detail: {
            Text("Select an item")
        }
    }
    
    private func addItem() {
        withAnimation {
            let newItem = Item(length: Measurement(value: .random(in: 0...99), unit: .meters))
            modelContext.insert(newItem)
        }
    }
    
    private func deleteItems(offsets: IndexSet) {
        withAnimation {
            for index in offsets {
                modelContext.delete(items[index])
            }
        }
    }
}

@Model
final class Item {
    var lengthMeters: Double? = nil
    @Transient var length: Measurement<UnitLength>? {
        get {
            if let lengthMeters {
                return Measurement<UnitLength>(value: lengthMeters, unit: .meters)
            } else { return nil }
        }
        set {
            lengthMeters = newValue?.value
        }
    }
    
    init(length: Measurement<UnitLength>) {
        self.length = length
    }
}

Solution

  • What you could do is to always convert the start value to the unit type of the users locale.

    struct SomeView: View {
        @State private var length: Measurement<UnitLength>
    
        init(input: Measurement<UnitLength>) {
            self._length = State(wrappedValue: input.converted(to: UnitLength(forLocale: .current)))
        }
        //...
    }
    
    

    This is straight forward but will give us the default length type for the locale which for instance is meters for the metric system so when calling the view like this

    SomeView(input: Measurement(value: 3, unit: .inches))
    

    the textfield will contain "0.0762" since it is in meters

    One way to work around this is to use the property measurementSystem on the locale and then set our preferred type depending on the system

    Here is one example

    init(input: Measurement<UnitLength>) {
        var unitType: UnitLength
        switch Locale.current.measurementSystem {
        case .metric:
            unitType = UnitLength.centimeters
        default:
            unitType = UnitLength.inches
        }
    
        self._length = State(wrappedValue: input.converted(to: unitType))
    }
    

    And now the TextField will contain a value in centimetres instead of the default meters


    Update

    If you want to let the user to select what measurement type to use I don't think you can do that automatically based on the users Locale or similar. Instead you need to hard code the available options per measurement system

    An example

    func availableUnitLengths() -> [UnitLength] {
        switch Locale.current.measurementSystem {
        case .metric:
            return [.centimeters, .decimeters, .meters]
        default:
            return [.inches, .feet, .yards]
        }
    }
    

    Then you can use this function in a picker

    @State private var unitLength: UnitLength = UnitLength(forLocale: .current)
    //...
    var body: some View {
        //...
        Picker("Length type", selection: $unitLength) {
           ForEach(availableUnitLengths(), id: \.self) {
               Text($0.symbol).tag($0)
           }
        }
        //...
    }
    

    And if you want to use this picker selection in a view I suggest you use Double for the property and only use Measurment to convert between length types when the picker selection is changed

    struct SomeView: View {
        let length: Measurement<UnitLength> = Measurement(value: 3, unit: .meters)
        @State private var unitLength: UnitLength = UnitLength(forLocale: .current)
        @State var value: Double = 0.0
    
        var body: some View {
            VStack(alignment: .leading) {
                Picker("Length type", selection: $unitLength) {
                    ForEach(availableUnitLengths(), id: \.self) {
                        Text($0.symbol).tag($0)
                    }
                }
                Text(String(value))
                TextField("length", value: $value, format: .number)
            }
            .onChange(of: unitLength, initial: true) {_, unitType in
                value = length.converted(to: unitType).value
            }
        }
    }
    

    I use let length ... to simplify the example, it represents the input to the view and for the "output" you need to convert value back to meters.