I've been struggling with Measurements when used in SwiftUI. I want to be able to convert measurements on the fly, but only the first conversion is working. All subsequent ones fail.
I've managed to narrow it down to a reproducible test case.
First, a quick check to see that Foundation works:
// Let’s check that conversion is possible, and works.
var test = Measurement<UnitLength>(value: 13.37, unit: .meters)
print("Original value: \(test.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
// prints: Original value: 13.37 m
test.convert(to: .centimeters)
print("In centimeters: \(test.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
// prints: In centimeters: 1,337 cm
test.convert(to: .kilometers)
print("In kilometers: \(test.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
// prints: In kilometers: 0.01337 km
test.convert(to: .meters)
print("Back to meters: \(test.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
// prints: Back to meters: 13.37 m
Okay, so it works on the Foundation level. I can convert measurements back and forth, many times.
Now run this ContentView below, and click/tap any button. First one will succeed, the other ones will fail.
struct ContentView: View {
@State var distance = Measurement<UnitLength>(value: 13.37, unit: .meters)
var body: some View {
VStack {
Text("Distance = \(distance.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
Button("Convert to cm") { print("Convert to cm"); distance.convert(to: .centimeters) }
Button("Convert to m") { print("Convert to m"); distance.convert(to: .meters) }
Button("Convert to km") { print("Convert to km"); distance.convert(to: .kilometers) }
}
.onChange(of: distance, perform: { _ in
print("→ new distance = \(distance.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
})
.frame(minWidth: 300)
.padding()
}
}
This is reproducible both on macOS and iOS.
Why on Earth does the first conversion succeed, then all subsequent ones fail? The onChange isn't even triggered.
I'm on Xcode 13.3, macOS 12.3, iOS 15.4
If you take a look at Measurement
's header you'll see it is a ReferenceConvertible
and the docs for that state "A decoration applied to types that are backed by a Foundation reference type." so it probably doesn't have the value semantics that @State
requires for SwiftUI to detect changes. Here is a workaround:
import SwiftUI
struct MTContentViewConfig: Equatable {
var distance = Measurement<UnitLength>(value: 13.37, unit: .meters)
var seed = 0
public mutating func convert(to otherUnit: UnitLength) {
distance.convert(to: otherUnit)
seed += 1
}
}
struct MTContentView: View {
@State var config = MTContentViewConfig()
var body: some View {
VStack {
Text("Distance = \(config.distance.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
Button("Convert to cm") { print("Convert to cm")
config.convert(to: .centimeters) }
Button("Convert to m") { print("Convert to m")
config.convert(to: .meters) }
Button("Convert to km") { print("Convert to km")
config.convert(to: .kilometers) }
}
.onChange(of: config) { newVal in
print("→ new distance = \(newVal.distance.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number)))")
}
.frame(minWidth: 300)
.padding()
}
}
By the way instead of doing manual formatting I would recommend supplying Text
with a MeasurementFormatter
, this way Text
will update the UILabel
automatically when region settings change.