/// A property wrapper that tracks changes of a property. /// /// This allows values to only publish changes if they have changed since the /// last time they were recieved. @propertyWrapper public struct TrackedChanges: Sendable { /// The current value wrapped in a tracking state. private var value: TrackingState /// Used to check if a new value is equal to an old value. private var isEqual: @Sendable (Value, Value) -> Bool /// Access to the underlying property that we are wrapping. public var wrappedValue: Value { get { value.currentValue } set { // Check if the new value is equal to the old value. guard !isEqual(newValue, value.currentValue) else { return } // If it's not equal then set it, as well as set the tracking to `.needsProcessed`. value = .needsProcessed(newValue) } } /// Create a new property that tracks it's changes. /// /// - Parameters: /// - wrappedValue: The value that we are wrapping. /// - needsProcessed: Whether this value needs processed (default = false). /// - isEqual: Method to compare old values against new values. public init( wrappedValue: Value, needsProcessed: Bool = false, isEqual: @escaping @Sendable (Value, Value) -> Bool ) { self.value = .init(wrappedValue, needsProcessed: needsProcessed) self.isEqual = isEqual } fileprivate enum TrackingState { case hasProcessed(Value) case needsProcessed(Value) init(_ value: Value, needsProcessed: Bool) { if needsProcessed { self = .needsProcessed(value) } else { self = .hasProcessed(value) } } var currentValue: Value { switch self { case let .hasProcessed(value): return value case let .needsProcessed(value): return value } } var needsProcessed: Bool { guard case .needsProcessed = self else { return false } return true } } /// Whether the value needs processed. var needsProcessed: Bool { value.needsProcessed } mutating func setHasProcessed() { value = .hasProcessed(value.currentValue) } public var projectedValue: Self { get { self } set { self = newValue } } } extension TrackedChanges: Equatable where Value: Equatable { public static func == (lhs: TrackedChanges, rhs: TrackedChanges) -> Bool { lhs.wrappedValue == rhs.wrappedValue && lhs.needsProcessed == rhs.needsProcessed } /// Create a new property that tracks it's changes, using the default equality check. /// /// - Parameters: /// - wrappedValue: The value that we are wrapping. /// - needsProcessed: Whether this value needs processed (default = false). public init( wrappedValue: Value, needsProcessed: Bool = false ) { self.init(wrappedValue: wrappedValue, needsProcessed: needsProcessed) { $0 == $1 } } } extension TrackedChanges: Hashable where Value: Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(wrappedValue) } }