Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
/// - Parameter tracking: A closure that contains properties to track
/// - Parameter context: 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
Copy Markdown
Contributor

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
Copy Markdown
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
/// - Parameter onChange: Invoked when the value of a property changes
/// - Parameter apply: Invoked when the value of a property changes

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

though, in general, I would promote the label onChange to be the parameter name and get rid of apply. That's more similar to withObservationTracking

/// - 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
163 changes: 162 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,45 @@ 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: tracking,
Comment thread
maximkrouk marked this conversation as resolved.
Outdated
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 +289,31 @@ private func onChange(
}
}

private func onChange(
of tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void,
perform action: @escaping @Sendable (_ transaction: UITransaction) -> Void,
apply: Bool = true,
task: @escaping @Sendable (
_ transaction: UITransaction,
_ operation: @escaping @Sendable () -> Void
) -> Void
) {
if apply { action(.current) }
Comment thread
maximkrouk marked this conversation as resolved.
Outdated

withPerceptionTracking {
tracking(.current)
} onChange: {
task(.current) {
onChange(
of: tracking,
perform: action,
apply: true,
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