SwiftUI and Measurement

Hello,

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 time will succeed, further times 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()
    }
}

Replacing distance.convert() with distance = distance.converted() does not help.

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.

// Edit: I'm on Xcode 13.3, macOS 12.3, iOS 15.4

I've found why SwiftUI doesn’t react to changes: it's because the hash for the distance value almost never changes:

// hash → distance
-7399245885813999995 → 13,37 m
-7399245885813999995 → 0,01337 km
-281655987593476708 → 1 337 cm
-7399245885813999995 → 13 370 mm

It seems like floating-point values (13.37, 0.01337 and 13370 -- which is stored as 13369.9999999) have their own hash, and integer values (1337) have another one.

Why is Measurement hashed this way, I don’t know. And as I can’t override the hash method, I'm bound to "box" Measurement into my own type:

struct MeasurementBox<UnitType>: Hashable where UnitType: Unit {
    var raw: Measurement<UnitType>

    func hash(into hasher: inout Hasher) {
        hasher.combine(raw.value)
        hasher.combine(raw.unit)
    }
    
    init(value: Double, unit: UnitType) {
        self.raw = .init(value: value, unit: unit)
    }

And this yields different hashes for different units:

3689241843365793539 → 13,37 m
-6289905827040915246 → 0,01337 km
5218327293601303449 → 1 337 cm
-237490459949056841 → 13 370 mm 

This is, at best, a workaround, but at least it works.

Hi Cyrille,

I just ran across this post, and if you haven't figured it out yet (or anyone else who finds this) the reason it's not working like you want is that @State cannot be used very well on complex data types. @State doesn't know which part of the value is changing. Anyway, the best way to overcome situations like this is to encapsulate your complex struct into a class and then call it as a @StateObject instead.

See the following:

class MyDistance: ObservableObject {
    @Published var distance: Measurement<UnitLength>
    init() {
        distance = Measurement<UnitLength>(value: 13.37, unit: .meters)
    }
}

struct ContentView: View {
    @StateObject var distance = MyDistance()
    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.distance = distance.distance.converted(to: .centimeters)
            }

            Button("Convert to m")  { print("Convert to m");  
                distance.distance = distance.distance.converted(to: .meters)
            }
            Button("Convert to km") { print("Convert to km"); 
                distance.distance = distance.distance.converted(to: .kilometers)
            }
        }
        .frame(minWidth: 300)
        .padding()
    }

}

I hope you find this useful.

SwiftUI and Measurement
 
 
Q