-
Notifications
You must be signed in to change notification settings - Fork 1.4k
/
NSObject+Observation.swift
209 lines (200 loc) · 7.16 KB
/
NSObject+Observation.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
#if canImport(Perception) && canImport(ObjectiveC)
import Foundation
import ObjectiveC
extension NSObject {
/// Observe access to properties of a `@Perceptible` or `@Observable` 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 accessed fields so that the view is always up-to-date.
///
/// It is most useful when dealing with non-SwiftUI views, such as UIKit views and controller.
/// You can invoke the ``observe(_:)`` method a single time in the `viewDidLoad` and update all
/// the view elements:
///
/// ```swift
/// override func viewDidLoad() {
/// super.viewDidLoad()
///
/// let countLabel = UILabel()
/// let incrementButton = UIButton(primaryAction: .init { _ in
/// store.send(.incrementButtonTapped)
/// })
///
/// observe { [weak self] in
/// guard let self
/// else { return }
///
/// countLabel.text = "\(store.count)"
/// }
/// }
/// ```
///
/// This closure is immediately called, allowing you to set the initial state of your UI
/// components from the feature's state. And if the `count` property in the feature's state is
/// ever mutated, this trailing closure will be called again, allowing us to update the view
/// again.
///
/// Generally speaking you can usually have a single ``observe(_:)`` in the entry point of your
/// view, such as `viewDidLoad` for `UIViewController`. This works even if you have many UI
/// components to update:
///
/// ```swift
/// override func viewDidLoad() {
/// super.viewDidLoad()
///
/// observe { [weak self] in
/// guard let self
/// else { return }
///
/// countLabel.isHidden = store.isObservingCount
/// if !countLabel.isHidden {
/// countLabel.text = "\(store.count)"
/// }
/// factLabel.text = store.fact
/// }
/// }
/// ```
///
/// This does mean that you may execute the line `factLabel.text = store.fact` even when something
/// unrelated changes, such as `store.count`, but that is typically OK for simple properties of
/// UI components. It is not a performance problem to repeatedly set the `text` of a label or
/// the `isHidden` of a button.
///
/// However, if there is heavy work you need to perform when state changes, then it is best to
/// put that in its own ``observe(_:)``. For example, if you needed to reload a table view or
/// collection view when a collection changes:
///
/// ```swift
/// override func viewDidLoad() {
/// super.viewDidLoad()
///
/// observe { [weak self] in
/// guard let self
/// else { return }
///
/// self.dataSource = store.items
/// self.tableView.reloadData()
/// }
/// }
/// ```
///
/// ## Navigation
///
/// The ``observe(_:)`` method makes it easy to drive navigation from state. To do so you need
/// a reference to the controller that you are presenting (held as an optional), and when state
/// becomes non-`nil` you assign and present the controller, and when state becomes `nil` you
/// dismiss the controller and `nil` out the reference.
///
/// For example, if your feature's state holds onto alert state, then an alert can be presented
/// and dismissed with the following:
///
/// ```swift
/// override func viewDidLoad() {
/// super.viewDidLoad()
///
/// var alertController: UIAlertController?
///
/// observe { [weak self] in
/// guard let self
/// else { return }
///
/// if
/// let store = store.scope(state: \.alert, action: \.alert),
/// alertController == nil
/// {
/// alertController = UIAlertController(store: store)
/// present(alertController!, animated: true, completion: nil)
/// } else if store.alert == nil, alertController != nil {
/// alertController?.dismiss(animated: true)
/// alertController = nil
/// }
/// }
/// }
/// ```
///
/// Here we are using the ``Store/scope(state:action:)-36e72`` operator for optional state in
/// order to detect when the `alert` state flips from `nil` to non-`nil` and vice-versa.
///
/// ## Cancellation
///
/// The method returns a ``ObservationToken`` that can be used to cancel observation. For example,
/// if you only want to observe while a view controller is visible, you can start observation in
/// the `viewWillAppear` and then cancel observation in the `viewWillDisappear`:
///
/// ```swift
/// var observation: ObservationToken?
///
/// func viewWillAppear() {
/// super.viewWillAppear()
/// self.observation = observe { [weak self] in
/// // ...
/// }
/// }
/// func viewWillDisappear() {
/// super.viewWillDisappear()
/// self.observation?.cancel()
/// }
/// ```
@discardableResult
public func observe(_ apply: @escaping () -> Void) -> ObservationToken {
if ObserveLocals.isApplying {
runtimeWarn(
"""
An "observe" was called from another "observe" closure, which can lead to \
over-observation and unintended side effects.
Avoid nested closures by moving child observation into their own lifecycle methods.
"""
)
}
let token = ObservationToken()
self.tokens.insert(token)
@Sendable func onChange() {
guard !token.isCancelled
else { return }
withPerceptionTracking(apply) {
Task { @MainActor in
guard !token.isCancelled
else { return }
ObserveLocals.$isApplying.withValue(true) {
onChange()
}
}
}
}
onChange()
return token
}
fileprivate var tokens: Set<ObservationToken> {
get {
(objc_getAssociatedObject(self, &NSObject.tokensHandle) as? Set<ObservationToken>) ?? []
}
set {
objc_setAssociatedObject(
self,
&NSObject.tokensHandle,
newValue,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}
private static var tokensHandle: UInt8 = 0
}
/// A token for cancelling observation created with ``ObjectiveC/NSObject/observe(_:)``.
public final class ObservationToken: NSObject, Sendable {
private let _isCancelled = LockIsolated(false)
fileprivate var isCancelled: Bool { self._isCancelled.value }
/// Cancels observation that was created with ``ObjectiveC/NSObject/observe(_:)``.
///
/// > Note: This cancellation is lazy and cooperative. It does not cancel the observation
/// immediately, but rather next time a change is detected by ``ObjectiveC/NSObject/observe(_:)``
/// it will cease any future observation.
public func cancel() { self._isCancelled.setValue(true) }
deinit {
self.cancel()
}
}
#endif
private enum ObserveLocals {
@TaskLocal static var isApplying = false
}