Skip to content
62 changes: 57 additions & 5 deletions Sources/SwiftNavigation/NSObject+Observe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
/// 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
/// You can invoke the ``observe(_:)-(()->Void)`` method a single time in the `viewDidLoad` and update all
/// the view elements:
///
/// ```swift
Expand All @@ -37,7 +37,7 @@
/// 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
/// Generally speaking you can usually have a single ``observe(_:)-(()->Void)`` in the entry point of your
/// view, such as `viewDidLoad` for `UIViewController`. This works even if you have many UI
/// components to update:
///
Expand All @@ -64,7 +64,7 @@
/// 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
/// put that in its own ``observe(_:)-(()->Void)``. For example, if you needed to reload a table view or
/// collection view when a collection changes:
///
/// ```swift
Expand Down Expand Up @@ -106,13 +106,36 @@
/// of a property changes.
/// - Returns: A cancellation token.
@discardableResult
public func observe(_ apply: @escaping @MainActor @Sendable () -> Void) -> ObserveToken {
public func observe(
_ 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.
/// 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(
_ context: @escaping @MainActor @Sendable () -> Void,
onChange apply: @escaping @MainActor @Sendable () -> Void
) -> ObserveToken {
observe { _ in
context()
} onChange: { _ in
apply()
}
}

/// Observe access to properties of an observable (or perceptible) object.
///
/// A version of ``observe(_:)-(()->Void)`` that is passed the current transaction.
///
/// - Parameter apply: A closure that contains properties to track and is invoked when the value
/// of a property changes.
Expand All @@ -134,6 +157,35 @@
return token
}

/// Observe access to properties of an observable (or perceptible) object.
///
/// A version of ``observe(_:onChange:)-(()->Void,_)`` 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(
_ context: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void,
onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction) -> Void
) -> ObserveToken {
let token = SwiftNavigation._observe { transaction in
MainActor._assumeIsolated {
context(transaction)
}
} onChange: { transaction in
MainActor._assumeIsolated {
apply(transaction)
}
} task: { transaction, work in
DispatchQueue.main.async {
withUITransaction(transaction, work)
}
}
tokens.append(token)
return token
}

fileprivate var tokens: [Any] {
get {
objc_getAssociatedObject(self, Self.tokensKey) as? [Any] ?? []
Expand Down
201 changes: 182 additions & 19 deletions Sources/SwiftNavigation/Observe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import ConcurrencyExtras
#if swift(>=6)
/// Tracks access to properties of an observable model.
///
/// This function allows one to minimally observe changes in a model in order to
/// This function is a convenient variant of ``observe(_:onChange:)-(()->Void,_)`` that
/// combines tracking context and onChange handler in one `apply` argument
/// and 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
Expand Down Expand Up @@ -50,24 +52,87 @@ import ConcurrencyExtras
///
/// And you can also build your own tools on top of `observe`.
///
/// - Parameters:
/// - isolation: The isolation of the observation.
/// - apply: A closure that contains properties to track.
/// - Parameter apply: A closure that contains properties to track.
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
/// is deallocated.
@inlinable
public func observe(
@_inheritActorContext _ apply: @escaping @isolated(any) @Sendable () -> Void
@_inheritActorContext
_ apply: @escaping @isolated(any) @Sendable () -> Void
) -> ObserveToken {
observe { _ in Result(catching: apply).get() }
}

/// Tracks access to properties of an observable model.
///
/// A version of ``observe(isolation:_:)`` that is handed the current ``UITransaction``.
/// 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] in model.count } onChange: { [countLabel, model] in
/// countLabel.text = "Count: \(model.count)"
/// }
/// ```
///
/// - Parameters:
/// - isolation: The isolation of the observation.
/// - apply: A closure that contains properties to track.
/// 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`.
///
/// - Parameter context: A closure that contains properties to track.
/// - Parameter apply: Invoked when the value of a property changes
/// > `onChange` is also invoked on initial call
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
@inlinable
public func observe(
@_inheritActorContext
_ context: @escaping @isolated(any) @Sendable () -> Void,
@_inheritActorContext
onChange apply: @escaping @isolated(any) @Sendable () -> Void
) -> ObserveToken {
observe(
{ _ in Result(catching: context).get() },
onChange: { _ in Result(catching: apply).get() }
)
}

/// Tracks access to properties of an observable model.
///
/// A version of ``observe(_:)-(()->Void)`` that is handed the current ``UITransaction``.
///
/// - Parameter apply: A closure that contains properties to track.
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
public func observe(
Expand All @@ -83,25 +148,115 @@ import ConcurrencyExtras
}
)
}

/// Tracks access to properties of an observable model.
///
/// A version of ``observe(_:onChange:)-(()->Void,_)`` that is handed the current ``UITransaction``.
///
/// - Parameter context: A closure that contains properties to track.
/// - Parameter apply: Invoked when the value of a property changes
/// > `onChange` is also invoked on initial call
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
public func observe(
@_inheritActorContext
_ context: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void,
onChange apply: @escaping @isolated(any) @Sendable (_ transaction: UITransaction) -> Void
) -> ObserveToken {
_observe(
context,
onChange: apply,
task: { transaction, operation in
Task {
await operation()
}
}
)
}
#endif

/// Observes changes in given context
///
/// - Parameter apply: Invoked when a change occurs in observed context
/// > `apply` is also invoked on initial call
/// - Parameter task: The task that wraps recursive observation calls
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
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)
}
) -> ObserveToken {
return SwiftNavigation.onChange(
of: apply,
perform: apply,
task: task
)
}

/// Observes changes in given context
///
/// - Parameter context: Observed context
/// - Parameter apply: Invoked when a change occurs in observed context
/// > `onChange` is also invoked on initial call
/// - Parameter task: The task that wraps recursive observation calls
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
func _observe(
_ context: @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 = SwiftNavigation.onChange(
of: context,
perform: apply,
task: task
)

apply(.current)
return token
}

/// Observes changes in given context
///
/// - Parameter context: Observed context
/// - Parameter operation: Invoked when a change occurs in observed context
/// > `operation` is not invoked on initial call
/// - Parameter task: The task that wraps recursive observation calls
/// - Returns: A token that keeps the subscription alive. Observation is cancelled when the token
/// is deallocated.
func onChange(
of context: @escaping @Sendable (_ transaction: UITransaction) -> Void,
perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void,
task: @escaping @Sendable (
_ transaction: UITransaction,
_ operation: @escaping @Sendable () -> Void
) -> Void = {
Task(operation: $1)
}
) -> ObserveToken {
let token = ObserveToken()
onChange(
{ [weak token] transaction in
SwiftNavigation.withRecursivePerceptionTracking(
of: { [weak token] transaction in
guard let token, !token.isCancelled else { return }
context(transaction)
},
perform: { [weak token] transaction in
guard
let token,
!token.isCancelled
else { return }

var perform: @Sendable () -> Void = { apply(transaction) }
var perform: @Sendable () -> Void = { operation(transaction) }
for key in transaction.storage.keys {
guard let keyType = key.keyType as? any _UICustomTransactionKey.Type
else { continue }
Expand All @@ -121,17 +276,25 @@ func _observe(
return token
}

private func onChange(
_ apply: @escaping @Sendable (_ transaction: UITransaction) -> Void,
private func withRecursivePerceptionTracking(
of context: @escaping @Sendable (_ transaction: UITransaction) -> Void,
perform operation: @escaping @Sendable (_ transaction: UITransaction) -> Void,
task: @escaping @Sendable (
_ transaction: UITransaction, _ operation: @escaping @isolated(any) @Sendable () -> Void
_ transaction: UITransaction,
_ operation: @escaping @Sendable () -> Void
) -> Void
) {
withPerceptionTracking {
apply(.current)
context(.current)
} onChange: {
task(.current) {
onChange(apply, task: task)
operation(.current)

withRecursivePerceptionTracking(
of: context,
perform: operation,
task: task
)
}
}
}
Expand Down
34 changes: 0 additions & 34 deletions Tests/SwiftNavigationTests/ObserveTests.swift

This file was deleted.

Loading
Loading