Binding: Equatable vs EquatableBinding
How I fixed a subtle bug in SwiftUI Pickers in my app by using a Property Wrapper instead of conforming Binding to Equatable.
It might sound counter-intuitive why I did that, but in my app RemafoX I needed to conform the Binding
type, which is shipped with SwiftUI to bind changes in views to data, to the Equatable
protocol to be able to pass a Binding object around in my apps data layer. The implementation of the extension looked like this:
extension Binding: Equatable where Value: Equatable {
static func == (left: Binding<Value>, right: Binding<Value>) -> Bool {
left.wrappedValue == right.wrappedValue
}
}
While I wasn't 100% happy with this solution, it worked fine when I first developed it so I shipped it. But then, with some macOS update a bug started to creep into all Picker views in my app. The pickers still worked most of the time, but sometimes they would behave strangely. The only behavior I could always reproduce was that when I set a selected value to the binding programmatically, and then later the user changed it to another value, it would show both values with a checkmark in the Picker dropdown, which is probably a bug somewhere in SwiftUI:
It took me hours of commenting out code to figure out the root cause, and it turned out to be the Binding
extension I mentioned above. Somehow, defining it in my app seems to have influenced the internal implementation of the Picker
view in SwiftUI. And knowing that, who knows what other side effects it might have caused or potentially cause in future system updates? So I clearly needed to find a better solution that shouldn't affect behavior in SwiftUI views.
The reason I wanted to make Binding
conform to Equatable
was that I had requirements on my data layer, namely some State
types in the TCA architecture, that required all data I stored in it to also conform to Equatable
. Something like:
struct AppState: Equatable {
// other properties
var configFile: Binding<ConfigFile>
}
The solution I found is quite simple, although I had to "learn" how to write a property wrapper as it was the first time I needed my own. But it was straight-forward, I believe you'll understand it even if you haven't ever written one:
@propertyWrapper
public struct EquatableBinding<Wrapped: Equatable>: Equatable {
public var wrappedValue: Binding<Wrapped>
public init(wrappedValue: Binding<Wrapped>) {
self.wrappedValue = wrappedValue
}
public static func == (left: EquatableBinding<Wrapped>, right: EquatableBinding<Wrapped>) -> Bool {
left.wrappedValue.wrappedValue == right.wrappedValue.wrappedValue
}
}
The only requirement of the @propertyWrapper
is the wrappedValue
property, and because I wanted to share this wrapper in my whole modularized application, I also had to write a public initializer, but it's all straightforward. The ==
function is the only requirement of the Equatable
protocol and it's also straightforward.
With this, I can now mark all my Binding
properties with @EquatableBinding
:
struct AppState: Equatable {
// other properties
@EquatableBinding<ConfigFile>
var configFile: Binding<ConfigFile>
}
And that's it, the type AppState
now is fully Equatable
, the weird dropdown issue is solved, and there's no risk for any side effects because I'm not conforming any existing types from other frameworks to new protocols. Instead, I just introduced a new type that other frameworks don't even know about, so they can't be affected. 🚀
The lesson to learn is to never extend types that you don't own with protocols that you don't own. There's even a Swift proposal to make this a compiler warning. Note that the solution isn't always a property wrapper. There are many ways to include your own types when conforming to protocols, just don't forget to do it.
A native Mac app that integrates with Xcode to help translate your app.
Get it now to save time during development & make localization easy.