|
1 | | -# equatable |
| 1 | +[](https://codecov.io/github/ordo-one/equatable) |
| 2 | +[](https://github.com/ordo-one/equatable/actions/workflows/swift-sanitizer-address.yml) |
| 3 | +[](https://github.com/ordo-one/equatable/actions/workflows/swift-sanitizer-thread.yml) |
| 4 | +[](https://github.com/ordo-one/equatable/actions/workflows/swift-linux-build.yml) |
| 5 | +[](https://github.com/ordo-one/equatable/actions/workflows/swift-macos-build.yml) |
2 | 6 |
|
3 | | -test |
| 7 | +# Equatable Macros |
| 8 | + |
| 9 | +A Swift package that provides macros for generating `Equatable` conformances for structs. |
| 10 | + |
| 11 | +## Overview |
| 12 | + |
| 13 | +The @Equatable macro generates an `Equatable` implementation that compares all of the struct's stored instance properties, excluding properties with SwiftUI property wrappers like @State and @Environment that trigger view updates through other mechanisms. Properties that aren't `Equatable` and don't affect the output of the view body can be marked with `@EquatableIgnored` to exclude them from the generated implementation. Closures are not permitted by default but can be marked with `@EquatableIgnoredUnsafeClosure` to indicate that they are safe to exclude from equality checks. |
| 14 | + |
| 15 | +## Installation |
| 16 | + |
| 17 | +Add this package to your project using Swift Package Manager: |
| 18 | + |
| 19 | +```swift |
| 20 | +dependencies: [ |
| 21 | + .package(url: "https://github.com/ordo-one/equatable.git", from: "1.0.0") |
| 22 | +] |
| 23 | +``` |
| 24 | + |
| 25 | +Then add the dependency to your target: |
| 26 | + |
| 27 | +```swift |
| 28 | +.target( |
| 29 | + name: "YourTarget", |
| 30 | + dependencies: [ |
| 31 | + .product(name: "Equatable", package: "equatable") |
| 32 | + ] |
| 33 | +) |
| 34 | +``` |
| 35 | + |
| 36 | +## Usage |
| 37 | + |
| 38 | +Apply the `@Equatable` macro to structs to automatically generate `Equatable` conformance: |
| 39 | + |
| 40 | +```swift |
| 41 | +import Equatable |
| 42 | +import SwiftUI |
| 43 | + |
| 44 | +@Equatable |
| 45 | +struct ProfileView: View { |
| 46 | + var username: String // Will be compared |
| 47 | + @State private var isLoading = false // Automatically skipped |
| 48 | + @ObservedObject var viewModel: ProfileViewModel // Automatically skipped |
| 49 | + @EquatableIgnored var cachedValue: String? // This property will be excluded |
| 50 | + @EquatableIgnoredUnsafeClosure var onTap: () -> Void // This closure is safe and will be ignored in comparison (in order for it to be safe we must be sure that this closure does not capture value types on call site) |
| 51 | + let id: UUID // Will be compared first for short-circuiting equality checks |
| 52 | + |
| 53 | + var body: some View { |
| 54 | + VStack { |
| 55 | + Text(username) |
| 56 | + if isLoading { |
| 57 | + ProgressView() |
| 58 | + } |
| 59 | + } |
| 60 | + } |
| 61 | +} |
| 62 | +``` |
| 63 | + |
| 64 | +The generated extension will implement the `==` operator with property comparisons ordered for optimal performance (e.g., IDs and simple types first): |
| 65 | + |
| 66 | +```swift |
| 67 | +extension ProfileView: Equatable { |
| 68 | + nonisolated public static func == (lhs: ProfileView, rhs: ProfileView) -> Bool { |
| 69 | + lhs.id == rhs.id && lhs.username == rhs.username |
| 70 | + } |
| 71 | +} |
| 72 | +``` |
| 73 | + |
| 74 | +## Safety Considerations |
| 75 | + |
| 76 | +Closures marked with `@EquatableIgnoredUnsafeClosure` should not affect the logical identity of the type. For example: |
| 77 | + |
| 78 | +### Exammple - Safe Usage of `@EquatableIgnoredUnsafeClosure` |
| 79 | +```swift |
| 80 | +struct UserActions: Equatable { |
| 81 | + let id: UUID |
| 82 | + let name: String |
| 83 | + @EquatableIgnoredUnsafeClosure |
| 84 | + var onTap: () -> Void |
| 85 | +} |
| 86 | +struct ContentView: View { |
| 87 | + var body: some View { |
| 88 | + UserActions(id: UUID(), name: "Example") { |
| 89 | + print("User tapped") // This closure does not capture value types on call site |
| 90 | + // and does not influence rendering of `UserActions` view's body. |
| 91 | + } |
| 92 | + } |
| 93 | +} |
| 94 | +``` |
| 95 | +In this example, `onTap` will be excluded from equality comparisons, allowing the `UserActions` instances to be properly compared based only on `id` and `name`. |
| 96 | + |
| 97 | +### Example - Unsafe Usage of `@EquatableIgnoredUnsafeClosure` |
| 98 | +```swift |
| 99 | +struct DemoView: View { |
| 100 | + @State var enabled = false |
| 101 | + var body: some View { |
| 102 | + Text("Enabled? \(enabled)") |
| 103 | + .onTapGesture(perform: { |
| 104 | + enabled.toggle() |
| 105 | + }) |
| 106 | + Content(enabled: enabled) |
| 107 | + } |
| 108 | +} |
| 109 | +struct Content: View { |
| 110 | + var enabled: Bool |
| 111 | + var body: some View { |
| 112 | + ViewTakesClosure( |
| 113 | + label: "This view takes a closure", |
| 114 | + onTapGesture: { |
| 115 | + // This will always print "enabled? False", because this `ViewTakesClosure` |
| 116 | + // is never re-rendered (its Equatable inputs never change). |
| 117 | + // The closure captures the initial value of `enabled=false`. |
| 118 | + print("enabled? \(enabled)") |
| 119 | + }) |
| 120 | + } |
| 121 | +} |
| 122 | +@Equabable |
| 123 | +struct ViewTakesClosure: View { |
| 124 | + let label: String |
| 125 | + @EquatableIgnoredUnsafeClosure let onTapGesture: () -> Void |
| 126 | + var body: some View { |
| 127 | + Text(label) |
| 128 | + .onTapGesture(perform: onTapGesture) |
| 129 | + } |
| 130 | +} |
| 131 | +``` |
| 132 | +In this example `ViewTakesClosure`'s closure captures the `enabled` value on callsite and since it's marked with `@EquatableIgnoredUnsafeClosure` |
| 133 | +it will not cause a re-render when the value of `enabled` changes. The closure will always print the initial value of `enabled` which is an incorrect behavior. |
| 134 | + |
| 135 | +## References |
| 136 | + |
| 137 | +This package is inspired by Cal Stephens' blog post [Understanding and Improving SwiftUI Performance](https://medium.com/airbnb-engineering/understanding-and-improving-swiftui-performance-37b77ac61896). |
0 commit comments