Interesting glitch with Measurement in SwiftUI
SwiftUI’s design takes advantage of equality to decide if things should be updated. Measurement
(backed by NSMeasurement
) has something interesting in its Equatable
implementation:
/// Compare two measurements of the same `Dimension`.
///
/// If `lhs.unit == rhs.unit`, returns `lhs.value == rhs.value`. Otherwise, converts `rhs` to the same unit as `lhs` and then compares the resulting values.
/// - returns: `true` if the measurements are equal.
public static func == <LeftHandSideType, RightHandSideType>(lhs: Measurement<LeftHandSideType>, rhs: Measurement<RightHandSideType>) -> Bool where LeftHandSideType : Unit, RightHandSideType : Unit
This means that 0kg is equal to 0g despite their unit being different. This makes sense for comparing weights but not for using it to decide to update labels. NSMeasurement
‘s equality isn’t documented but I loaded Foundation into Hopper and could see in int -[NSMeasurement isEqual:](int arg0) {
it did access doubleValue
and unit
so it looks like it is doing the same thing. This means that if you have grams like:
@State var measurement = Measurement(value: 0, unit: UnitMass.grams)
And change it to kilograms like this:
Button("Change") {
measurement = Measurement(value: 0, unit: UnitMass.kilograms)
}
Then body will not be called (even if the @State getter for measurement is being used).
Similarly if you are formatting a Measurement with Text like:
Text(measurement, format: .measurement(width: .narrow, usage: .asProvided))
// .asProvided: display using the provided unit without covering to one more appropriate for the magnitude or locale.
Then Text
doesn’t reformat if the unit is changed from grams to kilograms because it is likely using equality to decide that too. This second issue can be worked around by forcing a refresh by changing the .id()
as follows:
Text(measurement, format: .measurement(width: .narrow, usage: .asProvided))
.id(measurement.unit) // force reformat when unit changes.
As an experiment I attempted to swizzle NSMeasurement
‘s isEqual
method to make it compare the value and unit with no conversion and although that worked for NSMeasurement
, when using Measurement
and == that did not go through isEqual
so I assume the overlay also has some custom code in it and I’m not sure how to override the == of a struct. Overriding the equality for all measurements is a bad idea but it was an interesting experiment.
NSMeasurement.swizzle()
let a = NSMeasurement(doubleValue: 0, unit: UnitMass.grams)
let b = NSMeasurement(doubleValue: 0, unit: UnitMass.kilograms)
if a != b {
print("Fixed")
}
let x = Measurement(value: 0, unit: UnitMass.grams)
let y = Measurement(value: 0, unit: UnitMass.kilograms)
if x == y {
print("Not fixed")
}
extension NSMeasurement {
@objc func _swizzled_isEqual(_ to: NSMeasurement) -> Bool {
return doubleValue == to.doubleValue && unit == to.unit
}
static func swizzle() {
let originalSelector =
#selector(NSMeasurement.isEqual(_:))
let swizzledSelector =
#selector(NSMeasurement._swizzled_isEqual(_:))
let originalMethod =
class_getInstanceMethod (self, originalSelector)
let swizzledMethod =
class_getInstanceMethod (self, swizzledSelector)
method_exchangeImplementations(originalMethod!, swizzledMethod!)
}
}
The funny thing about this is when NSMeasurement is a class, it was possible to compare pointers to know if it was a different instance. But because Measurement is a struct the only way to compare is through Equatable’s ==. Meaning we are stuck with 0g == 0kg in Swift. In SwiftUI it could be possible to fix this with EquatableView
(i.e. a View
that conforms to Equatable
) however its ==
is only called when the View
is init, not when a @State
within is changed. I suddenly had the idea to try using NSMeasurement
for my State to take advantage of pointer equality and then cast to Measurement when formatting and that seems to fix body
being called when 0g is changed to 0kg, e.g.
@State var nsMeasurement = NSMeasurement(doubleValue: 0, unit: UnitMass.kilograms)
var measurement: Measurement<UnitMass> {
nsMeasurement as Measurement<UnitMass>
}
var measurementUnitBinding: Binding<UnitMass> {
Binding {
measurement.unit
} set: {
nsMeasurement = NSMeasurement(doubleValue: nsMeasurement.doubleValue, unit: $0)
}
}
var measurementValueBinding: Binding<Double> {
Binding {
nsMeasurement.doubleValue
} set: {
nsMeasurement = NSMeasurement(doubleValue: $0, unit: nsMeasurement.unit)
}
}
I still need to use .id(measurement.unit)
for Text
formatting though.