Skip to content

Bring back 'observe' for non-UIKit targets. #3295

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

Merged
merged 1 commit into from
Aug 22, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-navigation",
"state" : {
"revision" : "339ba3e305dc727b5baa6ddb476430a842167d4e",
"version" : "2.0.5"
"revision" : "70321c441d51b0b893c3abbf79687f550a027fde",
"version" : "2.0.6"
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ let package = Package(
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.3.5"),
.package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.1.0"),
.package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.2.0"),
.package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.0.5"),
.package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.0.6"),
.package(url: "https://github.com/pointfreeco/swift-perception", from: "1.3.4"),
.package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"),
Expand Down
178 changes: 178 additions & 0 deletions Sources/ComposableArchitecture/UIKit/NSObject+Observation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
#if canImport(Perception) && canImport(ObjectiveC) && !canImport(UIKit)
import Foundation
import ObjectiveC
import SwiftNavigation

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 ``ObserveToken`` 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: ObserveToken?
///
/// func viewWillAppear() {
/// super.viewWillAppear()
/// self.observation = observe { [weak self] in
/// // ...
/// }
/// }
/// func viewWillDisappear() {
/// super.viewWillDisappear()
/// self.observation?.cancel()
/// }
/// ```
@discardableResult
@_disfavoredOverload
public func observe(_ apply: @escaping () -> Void) -> ObserveToken {
let token = ObserveToken()
self.tokens.insert(token)
@Sendable func onChange() {
guard !token.isCancelled
else { return }

withPerceptionTracking(apply) {
Task { @MainActor in
guard !token.isCancelled
else { return }
onChange()
}
}
}
onChange()
return token
}

fileprivate var tokens: Set<ObserveToken> {
get {
(objc_getAssociatedObject(self, &NSObject.tokensHandle) as? Set<ObserveToken>) ?? []
}
set {
objc_setAssociatedObject(
self,
&NSObject.tokensHandle,
newValue,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}

private static var tokensHandle: UInt8 = 0
}
#endif