swiftuifoundationmeasurement

SwiftUI and Measurement conversions : only first conversion is performed


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


Solution

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