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
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
}
}
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.