Skip to content

Expose observe overloads for separate tracking and application of changes #286

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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() }
}

/// 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 @@ -62,6 +62,72 @@ import ConcurrencyExtras
observe(isolation: isolation) { _ in apply() }
}

/// 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 @@ -87,6 +153,36 @@ 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

private actor ActorProxy {
Expand All @@ -105,7 +201,8 @@ private actor ActorProxy {
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 @@ -138,6 +235,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 @@ -153,6 +292,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