Cyrille
Cyrille

Reputation: 25144

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

Upvotes: 1

Views: 595

Answers (2)

malhal
malhal

Reputation: 30669

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.

Upvotes: 1

Yrb
Yrb

Reputation: 9695

Measurement has not gotten a lot of love in the last few years from Apple. While this should work in SwiftUI, it is not. The answer is to do the conversion in a view model class, and let it update the published value. Something like this:

class MeasurementConverterViewModel: ObservableObject {
    @Published var distance: Measurement<UnitLength>
    
    init(distance: Measurement<UnitLength>) {
        self.distance = distance
    }
    
    public func convertToCM() {
        distance.convert(to: .centimeters)
    }
    
    public func convertToM() {
        distance.convert(to: .meters)
    }
    
    public func convertToKM() {
        distance.convert(to: .kilometers)
    }
}

This does work, though I would check Open Radar to see if anyone posted a bug report AND file the bug. This should work just as you coded in SwiftUI.

Upvotes: 0

Related Questions