Skip to content

Commit

Permalink
Compound observables (#8)
Browse files Browse the repository at this point in the history
* Experimental observer combining multiple observers

* Added basic map method

* Added compound proxies

* Documentation tweaks

* Add SPI documentation instruction
  • Loading branch information
PimCoumans authored Oct 11, 2023
1 parent a376da5 commit 31dc712
Show file tree
Hide file tree
Showing 13 changed files with 322 additions and 50 deletions.
4 changes: 4 additions & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: 1
builder:
configs:
documentation_targets: [DidUpdate]
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 models properties
@ObservedValue var count: Int = 0
}

Expand All @@ -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 ViewModels count property
lazy var stepper = StepperView(count: $viewModel.count)

func setupView() {
Expand All @@ -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 repositorys 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.
Expand Down Expand Up @@ -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` theres 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)")
Expand Down Expand Up @@ -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?
## ❓Thats it?
That’s about it! Please [let me know](https://twitter.com/pimcoumans) if you have any questions.
8 changes: 5 additions & 3 deletions Sources/DidUpdate/Observers/StateValueObserver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
public struct StateValueObserver {
private var observer: AnyObject

init<Value>(
_ observer: StateObserver.Observer<Value>
) {
init<Value>(_ observer: StateObserver.Observer<Value>) {
self.observer = observer
}

init(_ observers: [StateValueObserver]) {
self.observer = observers as AnyObject
}
}

extension StateValueObserver: Equatable, Hashable {
Expand Down
24 changes: 20 additions & 4 deletions Sources/DidUpdate/Property Wrappers/ExternallyUpdating.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ public struct ExternallyUpdating<Value>: UpdateObservable {
storage = ProxyStorage(valueProxy: valueProxy)
}

public var projectedValue: ExternallyUpdating<Value> { self }

public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> ExternallyUpdating<Subject> {
ExternallyUpdating<Subject>(valueProxy: ValueProxy(storage.proxy, keyPath: keyPath))
}
}

extension ExternallyUpdating {

public var currentValue: Value {
wrappedValue
}

public func addUpdateHandler(_ handler: UpdateHandler<Value>) -> Observer {
let localHandler = UpdateHandler(
updateWithCurrentValue: handler.updateWithCurrentValue,
Expand All @@ -44,9 +57,12 @@ public struct ExternallyUpdating<Value>: UpdateObservable {
return storage.proxy.addUpdateHandler(localHandler)
}

public var projectedValue: ExternallyUpdating<Value> { self }

public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> ExternallyUpdating<Subject> {
ExternallyUpdating<Subject>(valueProxy: ValueProxy(storage.proxy, keyPath: keyPath))
public func map<MappedValue>(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy<MappedValue> {
ReadOnlyProxy<MappedValue>(
get: { transform(wrappedValue) },
updateHandler: { update in
storage.proxy.updateHandler(update.mapped(using: transform))
}
)
}
}
2 changes: 1 addition & 1 deletion Sources/DidUpdate/Property Wrappers/ObservedValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public struct ObservedValue<Value> {
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<EnclosingSelf: ObservableState>(
_enclosingInstance instance: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
Expand Down
148 changes: 148 additions & 0 deletions Sources/DidUpdate/Property Wrappers/ReadOnlyProxy+compound.swift
Original file line number Diff line number Diff line change
@@ -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: UpdateObservable, B: UpdateObservable>(
_ 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: UpdateObservable, B: UpdateObservable, C: UpdateObservable>(
_ 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: UpdateObservable, B: UpdateObservable, C: UpdateObservable, D: UpdateObservable>(
_ 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<A: Equatable, B: Equatable>(
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<A: Equatable, B: Equatable>(
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<A: Equatable, B: Equatable, C: Equatable>(
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<A: Equatable, B: Equatable, C: Equatable>(
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<A: Equatable, B: Equatable, C: Equatable, D: Equatable>(
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<A: Equatable, B: Equatable, C: Equatable, D: Equatable>(
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)
}
)
}
}
13 changes: 13 additions & 0 deletions Sources/DidUpdate/Property Wrappers/ReadOnlyProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,22 @@ public struct ReadOnlyProxy<Value>: UpdateObservable {
}

extension ReadOnlyProxy {
public var currentValue: Value {
wrappedValue
}

public func addUpdateHandler(_ handler: UpdateHandler<Value>) -> Observer {
updateHandler(handler)
}

public func map<MappedValue>(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy<MappedValue> {
ReadOnlyProxy<MappedValue>(
get: { transform(wrappedValue) },
updateHandler: { update in
updateHandler(update.mapped(using: transform))
}
)
}
}

extension ReadOnlyProxy {
Expand Down
4 changes: 2 additions & 2 deletions Sources/DidUpdate/Property Wrappers/StoredValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public struct StoredValue<Value> {
}
}

/// 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<EnclosingSelf: ObservableState>(
_enclosingInstance instance: EnclosingSelf,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Value>,
Expand Down Expand Up @@ -63,7 +63,7 @@ public struct StoredValue<Value> {
storage storageKeyPath: ReferenceWritableKeyPath<EnclosingSelf, Self>
) -> ReadOnlyProxy<Value> {
get {
return ReadOnlyProxy(
ReadOnlyProxy(
get: {
instance[keyPath: storageKeyPath].storage
},
Expand Down
13 changes: 13 additions & 0 deletions Sources/DidUpdate/Property Wrappers/ValueProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,22 @@ public struct ValueProxy<Value>: UpdateObservable {
}

extension ValueProxy {
public var currentValue: Value {
wrappedValue
}

public func addUpdateHandler(_ handler: UpdateHandler<Value>) -> Observer {
updateHandler(handler)
}

public func map<MappedValue>(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy<MappedValue> {
.init(
get: { transform(wrappedValue) },
updateHandler: { update in
updateHandler(update.mapped(using: transform))
}
)
}
}

extension ValueProxy {
Expand Down
11 changes: 10 additions & 1 deletion Sources/DidUpdate/Property Wrappers/WeakValueProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public class WeakValueProxy<Value>: UpdateObservable {
let set: (Value) -> Void
let updateHandler: (UpdateHandler<Value>) -> StateValueObserver?

var currentValue: Value
public private(set) var currentValue: Value

public var wrappedValue: Value {
get {
Expand Down Expand Up @@ -56,6 +56,15 @@ extension WeakValueProxy {
let emptyObserver = StateObserver.Observer(keyPath: \Self.currentValue, handler: handler)
return StateValueObserver(emptyObserver)
}

public func map<MappedValue>(_ transform: @escaping (Value) -> MappedValue) -> ReadOnlyProxy<MappedValue> {
.init(
get: { transform(self.wrappedValue) },
updateHandler: { update in
self.addUpdateHandler(update.mapped(using: transform))
}
)
}
}

extension WeakValueProxy {
Expand Down
Loading

0 comments on commit 31dc712

Please sign in to comment.