From 31dc71271bd0acd75c3f3e18826aef692f37f1d6 Mon Sep 17 00:00:00 2001 From: Pim Date: Wed, 11 Oct 2023 17:58:23 +0200 Subject: [PATCH] Compound observables (#8) * Experimental observer combining multiple observers * Added basic map method * Added compound proxies * Documentation tweaks * Add SPI documentation instruction --- .spi.yml | 4 + Package.swift | 2 +- README.md | 10 +- .../Observers/StateValueObserver.swift | 8 +- .../ExternallyUpdating.swift | 24 ++- .../Property Wrappers/ObservedValue.swift | 2 +- .../ReadOnlyProxy+compound.swift | 148 ++++++++++++++++++ .../Property Wrappers/ReadOnlyProxy.swift | 13 ++ .../Property Wrappers/StoredValue.swift | 4 +- .../Property Wrappers/ValueProxy.swift | 13 ++ .../Property Wrappers/WeakValueProxy.swift | 11 +- .../Protocols/UpdateObservable.swift | 91 +++++++---- .../DidUpdateTests/ViewModelStateTests.swift | 42 ++++- 13 files changed, 322 insertions(+), 50 deletions(-) create mode 100644 .spi.yml create mode 100644 Sources/DidUpdate/Property Wrappers/ReadOnlyProxy+compound.swift diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..a396ac1 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + documentation_targets: [DidUpdate] diff --git a/Package.swift b/Package.swift index 00a0875..5a8f986 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.4 +// swift-tools-version: 5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/README.md b/README.md index 576cfdc..0e96a3f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ _So, like `ObservableObject` but without any of that SwiftUI or Combine stuff_ class MyView: UIView { /// Conform your model classes to `ObservableState` class ViewModel: ObservableState { - /// Use `ObservedValue` for your model's properties + /// Use `ObservedValue` for your model’s properties @ObservedValue var count: Int = 0 } @@ -34,7 +34,7 @@ class MyView: UIView { var observers: [StateValueObserver] = [] lazy var countLabel = UILabel() - // Pass value proxy to ViewModel's count property + // Pass value proxy to ViewModel’s count property lazy var stepper = StepperView(count: $viewModel.count) func setupView() { @@ -49,7 +49,7 @@ class MyView: UIView { *(basic counter sample code demonstrating updating a `ValueProxy` and `didUpdate` logic)* ## 📦 Installation -To add this dependency to your Xcode project, select File -> Add Package and enter this repository's URL: `https://github.com/PimCoumans/DidUpdate` +To add this dependency to your Xcode project, select File -> Add Package and enter this repository’s URL: `https://github.com/PimCoumans/DidUpdate` ## 🤷 But, why? SwiftUI is great, but for now I feel more comfortable using plain old UIKit for the more complex parts of my apps. I *do* love how SwiftUI lets you define state and have it automatically update all your views when anything changes. I wanted *that*, but not with the overhead of importing SwiftUI or Combine and using a bunch of publishers, or learning a whole new reactive library. @@ -89,7 +89,7 @@ func addObservers() { } ``` -Besides `didUpdate` there's also `didChange` indicating the value has actually changed (meaning not considered equal when conforming to `Equatable`): +Besides `didUpdate` there’s also `didChange` indicating the value has actually changed (meaning not considered equal when conforming to `Equatable`): ```swift let observer = $viewModel.username.didChange { username in print("Username has changed to: \(username)") @@ -123,5 +123,5 @@ let someSubView = SubView(username: $viewModel.username) Changing the username property in `SubView` in this example would automatically update the property in your viewModel. Reading the `username` property in `SubView` would give you the actual up-to-date value, even when changed from somewhere else (just like you’d expect from `@Binding`). -## ❓That's it? +## ❓That’s it? That’s about it! Please [let me know](https://twitter.com/pimcoumans) if you have any questions. diff --git a/Sources/DidUpdate/Observers/StateValueObserver.swift b/Sources/DidUpdate/Observers/StateValueObserver.swift index f531ee8..5f73fb0 100644 --- a/Sources/DidUpdate/Observers/StateValueObserver.swift +++ b/Sources/DidUpdate/Observers/StateValueObserver.swift @@ -2,11 +2,13 @@ public struct StateValueObserver { private var observer: AnyObject - init( - _ observer: StateObserver.Observer - ) { + init(_ observer: StateObserver.Observer) { self.observer = observer } + + init(_ observers: [StateValueObserver]) { + self.observer = observers as AnyObject + } } extension StateValueObserver: Equatable, Hashable { diff --git a/Sources/DidUpdate/Property Wrappers/ExternallyUpdating.swift b/Sources/DidUpdate/Property Wrappers/ExternallyUpdating.swift index c2402bf..fcd2473 100644 --- a/Sources/DidUpdate/Property Wrappers/ExternallyUpdating.swift +++ b/Sources/DidUpdate/Property Wrappers/ExternallyUpdating.swift @@ -35,6 +35,19 @@ public struct ExternallyUpdating: UpdateObservable { storage = ProxyStorage(valueProxy: valueProxy) } + public var projectedValue: ExternallyUpdating { self } + + public subscript(dynamicMember keyPath: WritableKeyPath) -> ExternallyUpdating { + ExternallyUpdating(valueProxy: ValueProxy(storage.proxy, keyPath: keyPath)) + } +} + +extension ExternallyUpdating { + + public var currentValue: Value { + wrappedValue + } + public func addUpdateHandler(_ handler: UpdateHandler) -> Observer { let localHandler = UpdateHandler( updateWithCurrentValue: handler.updateWithCurrentValue, @@ -44,9 +57,12 @@ public struct ExternallyUpdating: UpdateObservable { return storage.proxy.addUpdateHandler(localHandler) } - public var projectedValue: ExternallyUpdating { self } - - public subscript(dynamicMember keyPath: WritableKeyPath) -> ExternallyUpdating { - ExternallyUpdating(valueProxy: ValueProxy(storage.proxy, keyPath: keyPath)) + public func map(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy { + ReadOnlyProxy( + get: { transform(wrappedValue) }, + updateHandler: { update in + storage.proxy.updateHandler(update.mapped(using: transform)) + } + ) } } diff --git a/Sources/DidUpdate/Property Wrappers/ObservedValue.swift b/Sources/DidUpdate/Property Wrappers/ObservedValue.swift index 448e043..6691050 100644 --- a/Sources/DidUpdate/Property Wrappers/ObservedValue.swift +++ b/Sources/DidUpdate/Property Wrappers/ObservedValue.swift @@ -10,7 +10,7 @@ public struct ObservedValue { self.storage = wrappedValue } - /// Updates the enclosing ``ObservableState``'s ``StateObserver`` whenever the value is changed + /// Updates the enclosing ``ObservableState``’s ``StateObserver`` whenever the value is changed public static subscript( _enclosingInstance instance: EnclosingSelf, wrapped wrappedKeyPath: ReferenceWritableKeyPath, diff --git a/Sources/DidUpdate/Property Wrappers/ReadOnlyProxy+compound.swift b/Sources/DidUpdate/Property Wrappers/ReadOnlyProxy+compound.swift new file mode 100644 index 0000000..e515e3a --- /dev/null +++ b/Sources/DidUpdate/Property Wrappers/ReadOnlyProxy+compound.swift @@ -0,0 +1,148 @@ +import Foundation + +extension ReadOnlyProxy { + /// Combines two observables into one single ``ReadOnlyProxy`` with a tuple of both values + /// - Returns: New `ReadOnlyProxy` to use for handling updates or changes to any of the combined values + public static func compound( + _ a: A, _ b: B + ) -> Self where Value == (A.Value, B.Value) { + proxy(from: [a, b], getter: { (a.currentValue, b.currentValue) }) + } + + /// Combines three observables into one single ``ReadOnlyProxy`` with a tuple of all values + /// - Returns: New `ReadOnlyProxy` to use for handling updates or changes to any of the combined values + public static func compound( + _ a: A, _ b: B, _ c: C + ) -> Self where Value == (A.Value, B.Value, C.Value) { + proxy(from: [a, b, c], getter: { (a.currentValue, b.currentValue, c.currentValue) }) + } + + /// Combines four observables into one single ``ReadOnlyProxy`` with a tuple of all values + /// - Returns: New `ReadOnlyProxy` to use for handling updates or changes to any of the combined values + public static func compound( + _ a: A, _ b: B, _ c: C, _ d: D + ) -> Self where Value == (A.Value, B.Value, C.Value, D.Value) { + proxy(from: [a, b, c, d], getter: { (a.currentValue, b.currentValue, c.currentValue, d.currentValue) }) + } +} + +extension ReadOnlyProxy { + /// Adds an update handler called whenever the observed value has changed, comparing old and new value + /// - Parameters: + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing just the new value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated + public func didChange( + withCurrent provideCurrent: Bool = false, + handler: @escaping DidUpdateHandler + ) -> Observer where Value == (A, B) { + addUpdateHandler(.init( + shouldHandleUpdate: { $0.hasChangedValue(comparing: { $0 != $1 }) }, + updateWithCurrent: provideCurrent, + handler: handler + )) + } + + public func didChange( + withCurrent provideCurrent: Bool = false, + handler: @escaping FullDidUpdateHandler + ) -> Observer where Value == (A, B) { + addUpdateHandler(.init( + shouldHandleUpdate: { $0.hasChangedValue(comparing: { $0 != $1 }) }, + updateWithCurrent: provideCurrent, + handler: handler + )) + } + + /// Adds an update handler called whenever the observed value has changed, comparing old and new value + /// - Parameters: + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing just the new value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated + public func didChange( + withCurrent provideCurrent: Bool = false, + handler: @escaping DidUpdateHandler + ) -> Observer where Value == (A, B, C) { + addUpdateHandler(.init( + shouldHandleUpdate: { $0.hasChangedValue(comparing: { $0 != $1 }) }, + updateWithCurrent: provideCurrent, + handler: handler + )) + } + + /// Adds an update handler called whenever the observed value has changed, comparing old and new value + /// - Parameters: + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing the old and new value, and whether the closure was called with the current value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated + public func didChange( + withCurrent provideCurrent: Bool = false, + handler: @escaping FullDidUpdateHandler + ) -> Observer where Value == (A, B, C) { + addUpdateHandler(.init( + shouldHandleUpdate: { $0.hasChangedValue(comparing: { $0 != $1 }) }, + updateWithCurrent: provideCurrent, + handler: handler + )) + } + + /// Adds an update handler called whenever the observed value has changed, comparing old and new value + /// - Parameters: + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing just the new value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated + public func didChange( + withCurrent provideCurrent: Bool = false, + handler: @escaping DidUpdateHandler + ) -> Observer where Value == (A, B, C, D) { + addUpdateHandler(.init( + shouldHandleUpdate: { $0.hasChangedValue(comparing: { $0 != $1 }) }, + updateWithCurrent: provideCurrent, + handler: handler + )) + } + + /// Adds an update handler called whenever the observed value has changed, comparing old and new value + /// - Parameters: + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing the old and new value, and whether the closure was called with the current value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated + public func didChange( + withCurrent provideCurrent: Bool = false, + handler: @escaping FullDidUpdateHandler + ) -> Observer where Value == (A, B, C, D) { + addUpdateHandler(.init( + shouldHandleUpdate: { $0.hasChangedValue(comparing: { $0 != $1 }) }, + updateWithCurrent: provideCurrent, + handler: handler + )) + } +} + +extension ReadOnlyProxy { + private static func proxy( + from proxies: [any UpdateObservable], + getter: @escaping () -> Value + ) -> ReadOnlyProxy { + var previousValue = getter() + var wasInitial: Bool = true + return ReadOnlyProxy( + get: getter, + updateHandler: { handler in + let observers = proxies.map { + $0.didUpdate(withCurrent: handler.updateWithCurrentValue, handler: { _, _, isInitialUpdate in + let newValue = getter() + if isInitialUpdate && wasInitial { + handler.handle(update: .current(value: newValue)) + wasInitial = false + } else { + handler.handle(update: .updated(old: previousValue, new: newValue)) + } + previousValue = newValue + }) + } + return StateValueObserver(observers) + } + ) + } +} diff --git a/Sources/DidUpdate/Property Wrappers/ReadOnlyProxy.swift b/Sources/DidUpdate/Property Wrappers/ReadOnlyProxy.swift index 3a7a775..c3d1c11 100644 --- a/Sources/DidUpdate/Property Wrappers/ReadOnlyProxy.swift +++ b/Sources/DidUpdate/Property Wrappers/ReadOnlyProxy.swift @@ -22,9 +22,22 @@ public struct ReadOnlyProxy: UpdateObservable { } extension ReadOnlyProxy { + public var currentValue: Value { + wrappedValue + } + public func addUpdateHandler(_ handler: UpdateHandler) -> Observer { updateHandler(handler) } + + public func map(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy { + ReadOnlyProxy( + get: { transform(wrappedValue) }, + updateHandler: { update in + updateHandler(update.mapped(using: transform)) + } + ) + } } extension ReadOnlyProxy { diff --git a/Sources/DidUpdate/Property Wrappers/StoredValue.swift b/Sources/DidUpdate/Property Wrappers/StoredValue.swift index a396716..e654678 100644 --- a/Sources/DidUpdate/Property Wrappers/StoredValue.swift +++ b/Sources/DidUpdate/Property Wrappers/StoredValue.swift @@ -35,7 +35,7 @@ public struct StoredValue { } } - /// Updates the enclosing ``ObservableState``'s ``StateObserver`` whenever the value is changed + /// Updates the enclosing ``ObservableState``’s ``StateObserver`` whenever the value is changed public static subscript( _enclosingInstance instance: EnclosingSelf, wrapped wrappedKeyPath: ReferenceWritableKeyPath, @@ -63,7 +63,7 @@ public struct StoredValue { storage storageKeyPath: ReferenceWritableKeyPath ) -> ReadOnlyProxy { get { - return ReadOnlyProxy( + ReadOnlyProxy( get: { instance[keyPath: storageKeyPath].storage }, diff --git a/Sources/DidUpdate/Property Wrappers/ValueProxy.swift b/Sources/DidUpdate/Property Wrappers/ValueProxy.swift index e1587e3..e35dc92 100644 --- a/Sources/DidUpdate/Property Wrappers/ValueProxy.swift +++ b/Sources/DidUpdate/Property Wrappers/ValueProxy.swift @@ -34,9 +34,22 @@ public struct ValueProxy: UpdateObservable { } extension ValueProxy { + public var currentValue: Value { + wrappedValue + } + public func addUpdateHandler(_ handler: UpdateHandler) -> Observer { updateHandler(handler) } + + public func map(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy { + .init( + get: { transform(wrappedValue) }, + updateHandler: { update in + updateHandler(update.mapped(using: transform)) + } + ) + } } extension ValueProxy { diff --git a/Sources/DidUpdate/Property Wrappers/WeakValueProxy.swift b/Sources/DidUpdate/Property Wrappers/WeakValueProxy.swift index 56079e4..abddd81 100644 --- a/Sources/DidUpdate/Property Wrappers/WeakValueProxy.swift +++ b/Sources/DidUpdate/Property Wrappers/WeakValueProxy.swift @@ -6,7 +6,7 @@ public class WeakValueProxy: UpdateObservable { let set: (Value) -> Void let updateHandler: (UpdateHandler) -> StateValueObserver? - var currentValue: Value + public private(set) var currentValue: Value public var wrappedValue: Value { get { @@ -56,6 +56,15 @@ extension WeakValueProxy { let emptyObserver = StateObserver.Observer(keyPath: \Self.currentValue, handler: handler) return StateValueObserver(emptyObserver) } + + public func map(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy { + .init( + get: { transform(self.wrappedValue) }, + updateHandler: { update in + self.addUpdateHandler(update.mapped(using: transform)) + } + ) + } } extension WeakValueProxy { diff --git a/Sources/DidUpdate/Protocols/UpdateObservable.swift b/Sources/DidUpdate/Protocols/UpdateObservable.swift index 65b0b3c..c7b38d0 100644 --- a/Sources/DidUpdate/Protocols/UpdateObservable.swift +++ b/Sources/DidUpdate/Protocols/UpdateObservable.swift @@ -49,6 +49,18 @@ extension UpdateHandler { } } } + /// Creates update handler forwarding closures with transform applied to update + func mapped(using transform: @escaping (RootValue) -> Value) -> UpdateHandler { + .init( + updateWithCurrentValue: updateWithCurrentValue, + shouldHandleUpdate: shouldHandleUpdate.map { handler in + { handler($0.mapped(using: transform)) } + }, + handler: { update in + handler(update.mapped(using: transform)) + } + ) + } /// Creates update handler forwarding closures with keyPath applied to update func passThrough(from keyPath: KeyPath) -> UpdateHandler { @@ -70,24 +82,32 @@ public protocol UpdateObservable { associatedtype Value typealias Observer = StateValueObserver + /// Retrieves the current or most recent value of the observable + var currentValue: Value { get } + /// Not to be called directly, but rather implemented by types conforming to `UpdateObservable`. /// Implement this method to create a ``StateValueObserver`` with the provided ``UpdateHandler`` /// - Parameter handler: Update handler, properly configured through one of the `didUpdate` methods /// - Returns: Newly created ``StateValueObserver`` that calls the provide update handler func addUpdateHandler(_ handler: UpdateHandler) -> Observer + + /// Maps the value of the receiving observable to `MappedValue` + /// - Parameter transform: Closure that maps from `Value` to a new `MappedValue` + /// - Returns: ``ReadOnlyProxy`` with new value + func map(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy } extension UpdateObservable { - /// Closure providing just the new value of the update as it's only argument + /// Closure providing just the new value of the update as it’s only argument public typealias DidUpdateHandler = (_ newValue: Value) -> Void - /// Closure accepting all available arguments for updates: the previous value, the new value and wether the handler was called with just the current value + /// Closure accepting all available arguments for updates: the previous value, the new value and whether the handler was called with just the current value public typealias FullDidUpdateHandler = (_ oldValue: Value, _ newValue: Value, _ isCurrent: Bool) -> Void /// Adds an update handler called whenever the observed value updates /// - Parameters: - /// - provideLatestValue: Wether the provided closure should be called immediately with the current value + /// - provideLatestValue: Whether the provided closure should be called immediately with the current value /// - handler: Closure executed containing just the new value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didUpdate( withCurrent provideCurrent: Bool = false, handler: @escaping DidUpdateHandler @@ -100,9 +120,9 @@ extension UpdateObservable { /// Adds an update handler called whenever the observed value updates /// - Parameters: - /// - provideCurrent: Wether the provided closure should be called immediately with the current value - /// - handler: Closure executed containing the old and new value, and wether the closure was called with the current value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing the old and new value, and whether the closure was called with the current value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didUpdate( withCurrent provideCurrent: Bool = false, handler: @escaping FullDidUpdateHandler @@ -117,9 +137,9 @@ extension UpdateObservable { extension UpdateObservable where Value: Equatable { /// Adds an update handler called whenever the observed value has changed, comparing old and new value /// - Parameters: - /// - provideCurrent: Wether the provided closure should be called immediately with the current value + /// - provideCurrent: Whether the provided closure should be called immediately with the current value /// - handler: Closure executed containing just the new value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didChange( withCurrent provideCurrent: Bool = false, handler: @escaping DidUpdateHandler @@ -133,9 +153,9 @@ extension UpdateObservable where Value: Equatable { /// Adds an update handler called whenever the observed value has changed, comparing old and new value /// - Parameters: - /// - provideCurrent: Wether the provided closure should be called immediately with the current value - /// - handler: Closure executed containing the old and new value, and wether the closure was called with the current value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing the old and new value, and whether the closure was called with the current value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didChange( withCurrent provideCurrent: Bool = false, handler: @escaping FullDidUpdateHandler @@ -150,9 +170,15 @@ extension UpdateObservable where Value: Equatable { extension StateUpdate where Value: Equatable { @inlinable var hasChangedValue: Bool { + hasChangedValue(comparing: { $0 != $1 }) + } +} + +extension StateUpdate { + @inlinable func hasChangedValue(comparing comparer: (Value, Value) -> Bool) -> Bool { switch self { case .current: return true - case .updated(let old, let new): return old != new + case .updated(let old, let new): return comparer(old, new) } } } @@ -186,7 +212,17 @@ extension UpdateHandler where Value: Equatable { } extension StateUpdate { - /// Converts the update's values to the value at provided keyPath + /// Converts the update’s values using the provided transform closure + @inlinable + func mapped(using transform: (Value) -> Subject) -> StateUpdate { + switch self { + case .current(let value): + return .current(value: transform(value)) + case .updated(let old, let new): + return .updated(old: transform(old), new: transform(new)) + } + } + /// Converts the update’s values to the value at provided keyPath @inlinable func converted(with keyPath: KeyPath) -> StateUpdate { switch self { @@ -199,8 +235,7 @@ extension StateUpdate { } extension StateUpdate where Value: ExpressibleByNilLiteral { - - /// Converts the update's values to the value at provided keyPath + /// Converts the update’s values to the value at provided keyPath @inlinable func converted( with keyPath: KeyPath @@ -219,9 +254,9 @@ extension UpdateObservable where Value: ExpressibleByNilLiteral { /// so it does not need to be composed with a preceding `.?.` /// - Parameters: /// - keyPath: KeyPath to value to compare, making sure the update handler is only executed when value changed - /// - provideCurrent: Wether the provided closure should be called immediately with the current value + /// - provideCurrent: Whether the provided closure should be called immediately with the current value /// - handler: Closure executed containing just the new value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didChange( comparing keyPath: KeyPath, withCurrent provideCurrent: Bool = false, @@ -238,9 +273,9 @@ extension UpdateObservable where Value: ExpressibleByNilLiteral { /// so it does not need to be composed with a preceding `.?.` /// - Parameters: /// - keyPath: KeyPath to value to compare, making sure the update handler is only executed when value changed - /// - provideCurrent: Wether the provided closure should be called immediately with the current value - /// - handler: Closure executed containing the old and new value, and wether the closure was called with the current value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing the old and new value, and whether the closure was called with the current value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didChange( comparing keyPath: KeyPath, withCurrent provideCurrent: Bool = false, @@ -258,9 +293,9 @@ extension UpdateObservable { /// Adds an update handler called whenever the observed value has changed at the provided keyPath /// - Parameters: /// - keyPath: KeyPath to value to compare, making sure the update handler is only executed when value changed - /// - provideCurrent: Wether the provided closure should be called immediately with the current value + /// - provideCurrent: Whether the provided closure should be called immediately with the current value /// - handler: Closure executed containing just the new value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didChange( comparing keyPath: KeyPath, withCurrent provideCurrent: Bool = false, @@ -276,9 +311,9 @@ extension UpdateObservable { /// Adds an update handler called whenever the observed value has changed at the provided keyPath /// - Parameters: /// - keyPath: KeyPath to value to compare, making sure the update handler is only executed when value changed - /// - provideCurrent: Wether the provided closure should be called immediately with the current value - /// - handler: Closure executed containing the old and new value, and wether the closure was called with the current value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - provideCurrent: Whether the provided closure should be called immediately with the current value + /// - handler: Closure executed containing the old and new value, and whether the closure was called with the current value + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didChange( comparing keyPath: KeyPath, withCurrent provideCurrent: Bool = false, @@ -299,7 +334,7 @@ extension UpdateObservable { /// making sure the update handler is only executed when at least one of the value has changed /// - provideCurrent: Whether the provided closure should be called immediately with the current value /// - handler: Closure executed containing just the new value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didChange( comparing keyPaths: [KeyPath], withCurrent provideCurrent: Bool = false, @@ -319,7 +354,7 @@ extension UpdateObservable { /// making sure the update handler is only executed when at least one of the value has changed /// - provideCurrent: Whether the provided closure should be called immediately with the current value /// - handler: Closure executed containing just the new value - /// - Returns: Opaque class storing the observation, making sure the closure isn't called when deallocated + /// - Returns: Opaque class storing the observation, making sure the closure isn’t called when deallocated public func didChange( comparing keyPaths: [KeyPath], withCurrent provideCurrent: Bool = false, diff --git a/Tests/DidUpdateTests/ViewModelStateTests.swift b/Tests/DidUpdateTests/ViewModelStateTests.swift index d13fc8a..af6afdd 100644 --- a/Tests/DidUpdateTests/ViewModelStateTests.swift +++ b/Tests/DidUpdateTests/ViewModelStateTests.swift @@ -90,22 +90,20 @@ final class ViewModelStateTests: XCTestCase { } } - var observers: [StateValueObserver] = [] - func testOptionalObserving() { let view = SomeView() let bool = BooleanContainer() - view.$viewModel.optional.didChange(comparing: \.width) { newValue in + let observer = view.$viewModel.optional.didChange(comparing: \.width) { newValue in bool.value = true - }.add(to: &observers) + } + _ = observer // hush little 'never read' warning bool.expect(false) { view.viewModel.optional?.size.width = 2 } bool.expect(true) { view.viewModel.optional = .zero } bool.expect(true) { view.viewModel.optional?.size.width = 2 } bool.expect(false) { view.viewModel.optional?.size.width = 2 } bool.expect(true) { view.viewModel.optional = nil } - observers.removeAll() } func testUpdateHandlers() { @@ -180,4 +178,38 @@ final class ViewModelStateTests: XCTestCase { }) } } + + func testMapping() { + // Test functionality of mapping proxies + let view = SomeView() + let bool = BooleanContainer() + view.viewModel.frame = .zero + + let observer = view.$viewModel.frame.map(\.width).didChange { newValue in + bool.value = true + } + _ = observer // hush little 'never read' warning + bool.expect(true) { view.viewModel.frame.size.width = 20 } + bool.expect(false) { view.viewModel.frame.size.height = 20 } + } + + func testCompoundProxies() { + let view = SomeView() + let bool = BooleanContainer() + view.viewModel.frame = .zero + + let observer = ReadOnlyProxy.compound( + view.$viewModel.frame.width, + view.$viewModel.frame.height + ).didChange { width, height in + bool.value = true + } + _ = observer // hush little 'never read' warning + + bool.expect(true) { view.viewModel.frame.size.width = 20 } + bool.expect(false) { view.viewModel.frame.size.width = 20 } + bool.expect(true) { view.viewModel.frame.size.height = 20 } + bool.expect(false) { view.viewModel.frame.size.height = 20 } + bool.expect(false) { view.viewModel.frame.origin.x = 20 } + } }