Skip to content

Commit a8f3be2

Browse files
committed
Expose observe overloads for separate tracking and application of changes
1 parent db6bc9d commit a8f3be2

File tree

5 files changed

+458
-16
lines changed

5 files changed

+458
-16
lines changed

Sources/SwiftNavigation/NSObject+Observe.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,52 @@
110110
observe { _ in apply() }
111111
}
112112

113+
/// Observe access to properties of an observable (or perceptible) object.
114+
///
115+
/// This tool allows you to set up an observation loop so that you can access fields from an
116+
/// observable model in order to populate your view, and also automatically track changes to
117+
/// any fields accessed in the tracking parameter so that the view is always up-to-date.
118+
///
119+
/// - Parameter tracking: A closure that contains properties to track
120+
/// - Parameter onChange: Invoked when the value of a property changes
121+
/// - Returns: A cancellation token.
122+
@discardableResult
123+
public func observe(
124+
_ tracking: @escaping @MainActor @Sendable () -> Void,
125+
onChange apply: @escaping @MainActor @Sendable () -> Void
126+
) -> ObserveToken {
127+
observe { _ in apply() }
128+
}
129+
130+
/// Observe access to properties of an observable (or perceptible) object.
131+
///
132+
/// A version of ``observe(_:)`` that is passed the current transaction.
133+
///
134+
/// - Parameter tracking: A closure that contains properties to track
135+
/// - Parameter onChange: Invoked when the value of a property changes
136+
/// - Returns: A cancellation token.
137+
@discardableResult
138+
public func observe(
139+
_ tracking: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void,
140+
onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void
141+
) -> ObserveToken {
142+
let token = SwiftNavigation.observe { transaction in
143+
MainActor._assumeIsolated {
144+
tracking(transaction)
145+
}
146+
} onChange: { transaction in
147+
MainActor._assumeIsolated {
148+
apply(transaction)
149+
}
150+
} task: { transaction, work in
151+
DispatchQueue.main.async {
152+
withUITransaction(transaction, work)
153+
}
154+
}
155+
tokens.append(token)
156+
return token
157+
}
158+
113159
/// Observe access to properties of an observable (or perceptible) object.
114160
///
115161
/// A version of ``observe(_:)`` that is passed the current transaction.

Sources/SwiftNavigation/Observe.swift

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,72 @@ import ConcurrencyExtras
6262
observe(isolation: isolation) { _ in apply() }
6363
}
6464

65+
/// Tracks access to properties of an observable model.
66+
///
67+
/// This function allows one to minimally observe changes in a model in order to
68+
/// react to those changes. For example, if you had an observable model like so:
69+
///
70+
/// ```swift
71+
/// @Observable
72+
/// class FeatureModel {
73+
/// var count = 0
74+
/// }
75+
/// ```
76+
///
77+
/// Then you can use `observe` to observe changes in the model. For example, in UIKit you can
78+
/// update a `UILabel`:
79+
///
80+
/// ```swift
81+
/// observe { _ = model.value } onChange: { [weak self] in
82+
/// guard let self else { return }
83+
/// countLabel.text = "Count: \(model.count)"
84+
/// }
85+
/// ```
86+
///
87+
/// Anytime the `count` property of the model changes the trailing closure will be invoked again,
88+
/// allowing you to update the view. Further, only changes to properties accessed in the trailing
89+
/// closure will be observed.
90+
///
91+
/// > Note: If you are targeting Apple's older platforms (anything before iOS 17, macOS 14,
92+
/// > tvOS 17, watchOS 10), then you can use our
93+
/// > [Perception](http://github.com/pointfreeco/swift-perception) library to replace Swift's
94+
/// > Observation framework.
95+
///
96+
/// This function also works on non-Apple platforms, such as Windows, Linux, Wasm, and more. For
97+
/// example, in a Wasm app you could observe changes to the `count` property to update the inner
98+
/// HTML of a tag:
99+
///
100+
/// ```swift
101+
/// import JavaScriptKit
102+
///
103+
/// var countLabel = document.createElement("span")
104+
/// _ = document.body.appendChild(countLabel)
105+
///
106+
/// let token = observe { _ = model.count } onChange: {
107+
/// countLabel.innerText = .string("Count: \(model.count)")
108+
/// }
109+
/// ```
110+
///
111+
/// And you can also build your own tools on top of `observe`.
112+
///
113+
/// - Parameters:
114+
/// - isolation: The isolation of the observation.
115+
/// - tracking: A closure that contains properties to track.
116+
/// - onChange: A closure that is triggered after some tracked property has changed
117+
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
118+
/// is deallocated.
119+
public func observe(
120+
isolation: (any Actor)? = #isolation,
121+
@_inheritActorContext _ tracking: @escaping @Sendable () -> Void,
122+
@_inheritActorContext onChange apply: @escaping @Sendable () -> Void
123+
) -> ObserveToken {
124+
observe(
125+
isolation: isolation,
126+
{ _ in tracking() },
127+
onChange: { _ in apply() }
128+
)
129+
}
130+
65131
/// Tracks access to properties of an observable model.
66132
///
67133
/// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``.
@@ -87,6 +153,36 @@ import ConcurrencyExtras
87153
}
88154
)
89155
}
156+
157+
158+
/// Tracks access to properties of an observable model.
159+
///
160+
/// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``.
161+
///
162+
/// - Parameters:
163+
/// - isolation: The isolation of the observation.
164+
/// - tracking: A closure that contains properties to track.
165+
/// - onChange: A closure that is triggered after some tracked property has changed
166+
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
167+
/// is deallocated.
168+
public func observe(
169+
isolation: (any Actor)? = #isolation,
170+
@_inheritActorContext _ tracking: @escaping @Sendable (UITransaction) -> Void,
171+
@_inheritActorContext onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void
172+
) -> ObserveToken {
173+
let actor = ActorProxy(base: isolation)
174+
return observe(
175+
tracking,
176+
onChange: apply,
177+
task: { transaction, operation in
178+
Task {
179+
await actor.perform {
180+
operation()
181+
}
182+
}
183+
}
184+
)
185+
}
90186
#endif
91187

92188
private actor ActorProxy {
@@ -105,7 +201,8 @@ private actor ActorProxy {
105201
func observe(
106202
_ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void,
107203
task: @escaping @Sendable (
108-
_ transaction: UITransaction, _ operation: @escaping @Sendable () -> Void
204+
_ transaction: UITransaction,
205+
_ operation: @escaping @Sendable () -> Void
109206
) -> Void = {
110207
Task(operation: $1)
111208
}
@@ -138,6 +235,45 @@ func observe(
138235
return token
139236
}
140237

238+
func observe(
239+
_ tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void,
240+
onChange apply: @escaping @Sendable (_ transaction: UITransaction) -> Void,
241+
task: @escaping @Sendable (
242+
_ transaction: UITransaction,
243+
_ operation: @escaping @Sendable () -> Void
244+
) -> Void = {
245+
Task(operation: $1)
246+
}
247+
) -> ObserveToken {
248+
let token = ObserveToken()
249+
SwiftNavigation.onChange(
250+
of: tracking,
251+
perform: { [weak token] transaction in
252+
guard
253+
let token,
254+
!token.isCancelled
255+
else { return }
256+
257+
var perform: @Sendable () -> Void = { apply(transaction) }
258+
for key in transaction.storage.keys {
259+
guard let keyType = key.keyType as? any _UICustomTransactionKey.Type
260+
else { continue }
261+
func open<K: _UICustomTransactionKey>(_: K.Type) {
262+
perform = { [perform] in
263+
K.perform(value: transaction[K.self]) {
264+
perform()
265+
}
266+
}
267+
}
268+
open(keyType)
269+
}
270+
perform()
271+
},
272+
task: task
273+
)
274+
return token
275+
}
276+
141277
private func onChange(
142278
_ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void,
143279
task: @escaping @Sendable (
@@ -153,6 +289,31 @@ private func onChange(
153289
}
154290
}
155291

292+
private func onChange(
293+
of tracking: @escaping @Sendable (_ transaction: UITransaction) -> Void,
294+
perform action: @escaping @Sendable (_ transaction: UITransaction) -> Void,
295+
apply: Bool = true,
296+
task: @escaping @Sendable (
297+
_ transaction: UITransaction,
298+
_ operation: @escaping @Sendable () -> Void
299+
) -> Void
300+
) {
301+
if apply { action(.current) }
302+
303+
withPerceptionTracking {
304+
tracking(.current)
305+
} onChange: {
306+
task(.current) {
307+
onChange(
308+
of: tracking,
309+
perform: action,
310+
apply: true,
311+
task: task
312+
)
313+
}
314+
}
315+
}
316+
156317
/// A token for cancelling observation.
157318
///
158319
/// When this token is deallocated it cancels the observation it was associated with. Store this
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import SwiftNavigation
2+
import Perception
3+
import XCTest
4+
5+
class NestingObserveTests: XCTestCase {
6+
#if swift(>=6)
7+
func testIsolation() async {
8+
await MainActor.run {
9+
var count = 0
10+
let token = SwiftNavigation.observe {
11+
count = 1
12+
}
13+
XCTAssertEqual(count, 1)
14+
_ = token
15+
}
16+
}
17+
#endif
18+
19+
#if !os(WASI)
20+
@MainActor
21+
func testNestedObservation() async {
22+
let object = ParentObject()
23+
let model = ParentObject.Model()
24+
25+
MockTracker.shared.entries.removeAll()
26+
object.bind(model)
27+
28+
XCTAssertEqual(
29+
MockTracker.shared.entries.map(\.label),
30+
[
31+
"ParentObject.bind",
32+
"ParentObject.value.didSet",
33+
"ChildObject.bind",
34+
"ChildObject.value.didSet",
35+
]
36+
)
37+
38+
MockTracker.shared.entries.removeAll()
39+
model.child.value = 1
40+
41+
await Task.yield()
42+
43+
XCTAssertEqual(
44+
MockTracker.shared.entries.map(\.label),
45+
[
46+
"ChildObject.Model.value.didSet",
47+
"ChildObject.value.didSet",
48+
]
49+
)
50+
}
51+
#endif
52+
}
53+
54+
#if !os(WASI)
55+
fileprivate class ParentObject: @unchecked Sendable {
56+
var tokens: Set<ObserveToken> = []
57+
let child: ChildObject = .init()
58+
59+
var value: Int = 0 {
60+
didSet { MockTracker.shared.track(value, with: "ParentObject.value.didSet") }
61+
}
62+
63+
func bind(_ model: Model) {
64+
MockTracker.shared.track((), with: "ParentObject.bind")
65+
66+
tokens = [
67+
observe { _ = model.value } onChange: { [weak self] in
68+
self?.value = model.value
69+
},
70+
observe { _ = model.child } onChange: { [weak self] in
71+
self?.child.bind(model.child)
72+
}
73+
]
74+
}
75+
76+
@Perceptible
77+
class Model: @unchecked Sendable {
78+
var value: Int = 0 {
79+
didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet") }
80+
}
81+
82+
var child: ChildObject.Model = .init() {
83+
didSet { MockTracker.shared.track(value, with: "ParentObject.Model.value.didSet") }
84+
}
85+
}
86+
}
87+
88+
fileprivate class ChildObject: @unchecked Sendable {
89+
var tokens: Set<ObserveToken> = []
90+
91+
var value: Int = 0 {
92+
didSet { MockTracker.shared.track(value, with: "ChildObject.value.didSet") }
93+
}
94+
95+
func bind(_ model: Model) {
96+
MockTracker.shared.track((), with: "ChildObject.bind")
97+
98+
tokens = [
99+
observe { _ = model.value } onChange: { [weak self] in
100+
self?.value = model.value
101+
}
102+
]
103+
}
104+
105+
@Perceptible
106+
class Model: @unchecked Sendable {
107+
var value: Int = 0 {
108+
didSet { MockTracker.shared.track(value, with: "ChildObject.Model.value.didSet") }
109+
}
110+
}
111+
}
112+
113+
fileprivate final class MockTracker: @unchecked Sendable {
114+
static let shared = MockTracker()
115+
116+
struct Entry {
117+
var label: String
118+
var value: Any
119+
}
120+
121+
var entries: [Entry] = []
122+
123+
init() {}
124+
125+
func track(
126+
_ value: Any,
127+
with label: String
128+
) {
129+
entries.append(.init(label: label, value: value))
130+
}
131+
}
132+
#endif

0 commit comments

Comments
 (0)