Skip to content
46 changes: 46 additions & 0 deletions Sources/SwiftNavigation/NSObject+Observe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,52 @@
observe { _ in apply() }
}

/// Observe access to properties of an observable (or perceptible) object.
///
/// This tool allows you to set up an observation loop so that you can access fields from an
/// observable model in order to populate your view, and also automatically track changes to
/// any fields accessed in the tracking parameter so that the view is always up-to-date.
///
/// - Parameter tracking: A closure that contains properties to track
/// - Parameter onChange: Invoked when the value of a property changes
/// - Returns: A cancellation token.
@discardableResult
public func observe(
_ tracking: @escaping @MainActor @Sendable () -> Void,
onChange apply: @escaping @MainActor @Sendable () -> Void
) -> ObserveToken {
observe { _ in apply() }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't this function body ignore the tracking parameter? Could we have a unit test to check that this actually works?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed it has to be used and I fixed it in my local version a while ago, but didn't push it, I'll push an update tomorrow

}

/// Observe access to properties of an observable (or perceptible) object.
///
/// A version of ``observe(_:)`` that is passed the current transaction.
///
/// - Parameter tracking: A closure that contains properties to track
/// - Parameter onChange: Invoked when the value of a property changes
/// - Returns: A cancellation token.
@discardableResult
public func observe(
_ tracking: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void,
onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void
) -> ObserveToken {
let token = SwiftNavigation.observe { transaction in
MainActor._assumeIsolated {
tracking(transaction)
}
} onChange: { transaction in
MainActor._assumeIsolated {
apply(transaction)
}
} task: { transaction, work in
DispatchQueue.main.async {
withUITransaction(transaction, work)
}
}
tokens.append(token)
return token
}

/// Observe access to properties of an observable (or perceptible) object.
///
/// A version of ``observe(_:)`` that is passed the current transaction.
Expand Down
164 changes: 163 additions & 1 deletion Sources/SwiftNavigation/Observe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,72 @@ import ConcurrencyExtras
observe { _ in Result(catching: apply).get() }
}

/// Tracks access to properties of an observable model.
///
/// This function allows one to minimally observe changes in a model in order to
/// react to those changes. For example, if you had an observable model like so:
///
/// ```swift
/// @Observable
/// class FeatureModel {
/// var count = 0
/// }
/// ```
///
/// Then you can use `observe` to observe changes in the model. For example, in UIKit you can
/// update a `UILabel`:
///
/// ```swift
/// observe { _ = model.value } onChange: { [weak self] in
/// guard let self else { return }
/// countLabel.text = "Count: \(model.count)"
/// }
/// ```
///
/// Anytime the `count` property of the model changes the trailing closure will be invoked again,
/// allowing you to update the view. Further, only changes to properties accessed in the trailing
/// closure will be observed.
///
/// > Note: If you are targeting Apple's older platforms (anything before iOS 17, macOS 14,
/// > tvOS 17, watchOS 10), then you can use our
/// > [Perception](http://github.com/pointfreeco/swift-perception) library to replace Swift's
/// > Observation framework.
///
/// This function also works on non-Apple platforms, such as Windows, Linux, Wasm, and more. For
/// example, in a Wasm app you could observe changes to the `count` property to update the inner
/// HTML of a tag:
///
/// ```swift
/// import JavaScriptKit
///
/// var countLabel = document.createElement("span")
/// _ = document.body.appendChild(countLabel)
///
/// let token = observe { _ = model.count } onChange: {
/// countLabel.innerText = .string("Count: \(model.count)")
/// }
/// ```
///
/// And you can also build your own tools on top of `observe`.
///
/// - Parameters:
/// - isolation: The isolation of the observation.
/// - tracking: A closure that contains properties to track.
/// - onChange: A closure that is triggered after some tracked property has changed
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
public func observe(
isolation: (any Actor)? = #isolation,
@_inheritActorContext _ tracking: @escaping @Sendable () -> Void,
@_inheritActorContext onChange apply: @escaping @Sendable () -> Void
) -> ObserveToken {
observe(
isolation: isolation,
{ _ in tracking() },
onChange: { _ in apply() }
)
}

/// Tracks access to properties of an observable model.
///
/// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``.
Expand All @@ -83,12 +149,43 @@ import ConcurrencyExtras
}
)
}


/// Tracks access to properties of an observable model.
///
/// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``.
///
/// - Parameters:
/// - isolation: The isolation of the observation.
/// - tracking: A closure that contains properties to track.
/// - onChange: A closure that is triggered after some tracked property has changed
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
public func observe(
isolation: (any Actor)? = #isolation,
@_inheritActorContext _ tracking: @escaping @Sendable (UITransaction) -> Void,
@_inheritActorContext onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void
) -> ObserveToken {
let actor = ActorProxy(base: isolation)
return observe(
tracking,
onChange: apply,
task: { transaction, operation in
Task {
await actor.perform {
operation()
}
}
}
)
}
#endif

func _observe(
_ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void,
task: @escaping @Sendable (
_ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void
_ transaction: UITransaction,
_ operation: @escaping @Sendable () -> Void
) -> Void = {
Task(operation: $1)
}
Expand Down Expand Up @@ -121,6 +218,48 @@ func _observe(
return token
}

func observe(
_ tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void,
onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void,
task: @escaping @Sendable (
_ transaction: UITransaction,
_ operation: @escaping @Sendable () -> Void
) -> Void = {
Task(operation: $1)
}
) -> ObserveToken {
let token = ObserveToken()
SwiftNavigation.onChange(
of: { [weak token] transaction in
guard let token, !token.isCancelled else { return }
tracking(transaction)
},
perform: { [weak token] transaction in
guard
let token,
!token.isCancelled
else { return }

var perform: @Sendable () -> Void = { apply(transaction) }
for key in transaction.storage.keys {
guard let keyType = key.keyType as? any _UICustomTransactionKey.Type
else { continue }
func open<K: _UICustomTransactionKey>(_: K.Type) {
perform = { [perform] in
K.perform(value: transaction[K.self]) {
perform()
}
}
}
open(keyType)
}
perform()
},
task: task
)
return token
}

private func onChange(
_ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void,
task: @escaping @Sendable (
Expand All @@ -136,6 +275,29 @@ private func onChange(
}
}

private func onChange(
of tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void,
perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void,
task: @escaping @Sendable (
_ transaction: UITransaction,
_ operation: @escaping @Sendable () -> Void
) -> Void
) {
operation(.current)

withPerceptionTracking {
tracking(.current)
} onChange: {
task(.current) {
onChange(
of: tracking,
perform: operation,
task: task
)
}
}
}

/// A token for cancelling observation.
///
/// When this token is deallocated it cancels the observation it was associated with. Store this
Expand Down
132 changes: 132 additions & 0 deletions Tests/SwiftNavigationTests/ObserveTests+Nesting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import SwiftNavigation
import Perception
import XCTest

class NestingObserveTests: XCTestCase {
#if swift(>=6)
func testIsolation() async {
await MainActor.run {
var count = 0
let token = SwiftNavigation.observe {
count = 1
}
XCTAssertEqual(count, 1)
_ = token
}
}
#endif

#if !os(WASI)
@MainActor
func testNestedObservation() async {
let object = ParentObject()
let model = ParentObject.Model()

MockTracker.shared.entries.removeAll()
object.bind(model)

XCTAssertEqual(
MockTracker.shared.entries.map(\.label),
[
"ParentObject.bind",
"ParentObject.value.didSet",
"ChildObject.bind",
"ChildObject.value.didSet",
]
)

MockTracker.shared.entries.removeAll()
model.child.value = 1

await Task.yield()

XCTAssertEqual(
MockTracker.shared.entries.map(\.label),
[
"ChildObject.Model.value.didSet",
"ChildObject.value.didSet",
]
)
}
#endif
}

#if !os(WASI)
fileprivate class ParentObject: @unchecked Sendable {
var tokens: Set<ObserveToken> = []
let child: ChildObject = .init()

var value: Int = 0 {
didSet { MockTracker.shared.track(value, with: "ParentObject.value.didSet") }
}

func bind(_ model: Model) {
MockTracker.shared.track((), with: "ParentObject.bind")

tokens = [
observe { _ = model.value } onChange: { [weak self] in
self?.value = model.value
},
observe { _ = model.child } onChange: { [weak self] in
self?.child.bind(model.child)
}
]
}

@Perceptible
class Model: @unchecked Sendable {
var value: Int = 0 {
didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet") }
}

var child: ChildObject.Model = .init() {
didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet") }
}
}
}

fileprivate class ChildObject: @unchecked Sendable {
var tokens: Set<ObserveToken> = []

var value: Int = 0 {
didSet { MockTracker.shared.track(value, with: "ChildObject.value.didSet") }
}

func bind(_ model: Model) {
MockTracker.shared.track((), with: "ChildObject.bind")

tokens = [
observe { _ = model.value } onChange: { [weak self] in
self?.value = model.value
}
]
}

@Perceptible
class Model: @unchecked Sendable {
var value: Int = 0 {
didSet { MockTracker.shared.track(value, with: "ChildObject.Model.value.didSet") }
}
}
}

fileprivate final class MockTracker: @unchecked Sendable {
static let shared = MockTracker()

struct Entry {
var label: String
var value: Any
}

var entries: [Entry] = []

init() {}

func track(
_ value: Any,
with label: String
) {
entries.append(.init(label: label, value: value))
}
}
#endif
Loading
Loading