[Pitch] Observation

Observation

Introduction

Making apps that are responsive often requires mechanisms to observe changes that are applied from a model to drive changes elsewhere in the presentation of that data. The observer pattern allows a subject to maintain a list of observers and notifies them of specific or general state changes. This has the advantage of not directly coupling objects together; an object that is observed needs no information about the observer other than it is an observer. In addition to the decoupled nature it also has an implicit distribution of the updates across potential multiple observers.

This design pattern is a well traveled path by many languages and Swift has an opportunity to provide a robust, type safe, and performant mechanism for offering this pattern as a low level uniform system. This proposal defines what an observable reference is, what an observer needs to conform to, and the connection between those.

Motivation

There are already a few mechanisms for observation in Swift. Some of these include Key Value Observing (KVO), or ObservableObject; but those are relegated to in the case of KVO to just NSObject descendants, and ObservableObject requires using Combine which is restricted to Darwin platforms and does not leverage language features like async/await or AsyncSequence. By taking experience from those existing systems we can build a more generally useful feature that applies to all Swift reference types; not just those that inherit from NSObject and have it work cross platform with using the advantages from low level language features like async/await.

Prior art

KVO

Key Value Observing in Objective-C has served that model well but is limited to just systems based on inheritance to NSObject. The APIs only offer the intercepting of events (meaning that the callout to the inform of the changes is between the will/did events). KVO has great flexibility with the granularity of events but lacks in composability. Observers for KVO must inherit from NSObject and rely on the objc runtime to track the changes that occur. Even though the interface for KVO has been updated to utilize the more modern Swift keypath constructs that are strongly typed, under the hood it is still stringly typed events.

Combine

Combine's ObservableObject produces changes on the leading edge of the will/did events and all delivered values are before the value did set. Albeit this serves SwiftUI well, it is restrictive for non SwiftUI usage and can be surprising to developers first encountering that restriction. ObservableObject also requires that all observed properties to be marked as @Published to interact with the change events. In most cases this requirement is applied to every single property and becomes redundant to the developer; meaning folks writing an ObservableObject conforming type must repeatedly (with little to no true gained clarity) annotate each property - in the end this results in a fatigue of the meaning of what is a participating item or not.

Proposed solution

A formalized observer pattern needs to support the following:

  • Marking a reference type as "observable"
  • Tracking changes within an instance of an observable type
  • The ability to observe and utilize those changes from somewhere else, e.g. another type

A type can declare itself as observable simply by conforming to the Observable protocol:

final class MyObject: Observable {
  var someProperty: String = ""
  var someOtherProperty = 0
}

Unlike ObservableObject and @Published, none of the fields of the type need to be individually marked as observable. Instead, all fields of the observable type are implicitly observable. Since creating key paths is limited to the visibility of the fields this means that the control of observation is relegated to the visibility of the fields. If a developer needs to restrict the observation of a field, it can be marked as private which prevents external access to the key path from being constructed and consequently observation.

The Observable protocol includes a set of extension methods to handle observation. In the simplest, most common case, a client can use the changes(for:) method to observe changes to that field for a given instance.

func processChanges(_ object: MyObject) async {
  for await change in object.changes(for: \.someProperty) {
    print(change.newValue)
  }
}

This allows users of this protocol to be able to observe the changes to specific values either as a distinct step in the chain of change events or as an asynchronous sequence of change events. The advantage of the changes is specifically type safe since it can only be changes applied from one specific field.

object.someProperty = "hello" 
// prints "hello" in the awaiting loop
object.someOtherProperty += 1
// nothing is printed

Detailed design

Observation of an entity implies that entity has identity. That means that things that are observable are inherently reference types. Structural types that are not rooted in a reference semantic storage do not have a well formed concept of external observation of changes. However, if the structure is a member variable of a reference type, descendant key paths to specific values passing through structural types with a root of a reference type do make sense as being an observable field.

In order to mark a reference type as being able to be observed Swift has an existing mechanism to do so: protocols. This observable protocol will only have two requirements; 1) that the type is a reference type and 2) that it has it's state changes observed. The requirement for being a reference type is only present to enforce the concept of reference semantics; if a structural type was observable it would immediately violate the concept of it's value-type-ness. The second requirement is the root functionality that being observable is defined by.

public protocol Observable: AnyObject {
  associatedtype Observation
  
  func addObserver<Member>(_ observer: some Observer<Self, Member>, for keyPath: KeyPath<Self, Member>) -> Observation
  func addChangeHandler(for fields: ObservationTracking.Fields<Self>, options: ObservationTracking.Options, _ handler: @Sendable @escaping () -> Void) -> Observation
  func removeObservation(_ observation: Observation)
}

extension Observable {
  public func changes<Member>(for keyPath: KeyPath<Self, Member>) -> ObservedChanges<Self, Member> { ... }
}

For cases where developers want to offer change events directly but do not want to manage the list of observers themselves a default storage type is included. The ObservationList type is specifically crafted to allow for composition of observation with other systems. It provides type safe and concurrency safe addition and removal of observations and custom storage accessors for manipulating fields.

public struct ObservationList<Subject: Observable, Storage> {
  public func addObserver<Member>(
    _ observer: some Observer<Subject, Member>, 
    for keyPath: KeyPath<Subject, Member>
  ) -> ObservationTracking.Token 
  
  public func addChangeHandler(
    for fields: ObservationTracking.Fields<Self>, 
    options: ObservationTracking.Options = [.willSet, .compareEquality], 
    _ handler: @Sendable @escaping () -> Void
  ) -> ObservationTracking.Token
  
  public func removeObservation(_ observation: ObservationTracking.Token)
  
  public func getMember<Member>(
    _ subject: Subject,
    propertyKeyPath: KeyPath<Subject, Member>,
    storageKeyPath: KeyPath<Storage, Member>,
    storage: Storage
  ) -> Member
  
  public func setMember<Member>(
    _ subject: Subject,
    propertyKeyPath: KeyPath<Subject, Member>,
    storageKeyPath: WritableKeyPath<Storage, Member>,
    storage: inout Storage,
    newValue: Member
  )

The concept of tracking changes is the fundamental mechanism in which any Observable type is able to provide changes to observe. By default the concept of observation is transitively forwarded; observing a keypath of \A.b.c.d means that if the field .b is Observable that is registered with an observer to track \B.c.d and so on. This means that graphs of observations can be tracked such that any set of changes are forwarded as an event.

Observations come in a few different forms. An observer can be just an iteration of events over time of the post change event of one specific field or any field of the storage of the Observable instance. Alternatively, it can be an observation of the events before a value is changed. These two categories are the concept of didSet and willSet either specifically targeted at a particular field or as any potential field.

Capturing the will and did set events can be then modeled by a protocol.

public protocol Observer<Subject: Observed> {
  mutating func subjectWillSet<Member>(_ keyPath: KeyPath<Subject, Member>, newValue: Member)
  mutating func subjectDidSet<Member>(_ keyPath: KeyPath<Subject, Member>, newValue: Member)
}

extension Observer {
  public mutating func subjectWillSet<Member>(_ keyPath: KeyPath<Subject, Member>, newValue: Member) { }
  public mutating func subjectDidSet<Member>(_ keyPath: KeyPath<Subject, Member>, newValue: Member) { }
}

A more formal set of changes for either a given field or any field can then be modeled as change event values over time. In swift the language model of that type of values asynchronously over time is represented as AsyncSequence.

public struct ObservedChanges<Observed: Observable, Member> {
  public struct Element {
    public var newValue: Member
    public var keyPath: KeyPath<Subject, Member>
  }
}

extension ObservedChanges: Sendable where Observed: Sendable, Member: Sendable { }

extension ObservedChanges: AsyncSequence {
  public struct Iterator: AsyncIteratorProtocol {
    public mutating func next() async -> Element?
  }
  
  public func makeAsyncIterator() -> Iterator
}

@availability(*, unavailable)
extension ObservedChanges.Iterator: Sendable { }

A key area of focus that other systems can face issues for is lifetime management. The ownership model for Observed types will strongly own observers for the lifetime of their registration. Observations and the AsyncSequences do not maintain strong references to the Observed instance. At the point of iteration of the ObservedChanges will return nil from their iterator when the reference to the Observed instance has become weakly loaded as nil. In short observation via iteration terminates when the observed object is no longer valid. That means in the common case there is no opportunity for invalid or stalled observations.

Since all observations have hooks into both the read as well as the write this allows for the affordance to track just the changes to properties that were accessed within a given scope. Unlike other systems, that observation can be done efficiently and with key options to grant pruning behaviors that are not easily approachable in a straightforward mechanism. The access can track only the accessed properties, determine if events need to be fired on the leading edge (willSet) or trailing edge (didSet) and account for equatability (setting the same value). In the case for UI or other widgeting systems it is commonly tracked on the leading edge, values read during the construction of the main contents of a view, and equatable values being set over to the same value are ideally not fired for a change event.

public struct ObservationTracking: @unchecked Sendable {
  public struct Token: Hashable, Sendable {
    public init()
  }
  
  public struct Fields<Subject: Observable>: Hashable, @unchecked Sendable {
    public init(keyPaths: Set<PartialKeyPath<Subject>>)
  }
  
  public struct Options: OptionSet, Sendable {
    public var rawValue: Int

    public init(rawValue: Int)

    public static var willSet: Options { get }
    public static var didSet: Options { get }
    public static var compareEquality: Options { get }
  }
  
  public func addChangeHandler(options: Options = [.willSet, .compareEquality], _ handler: @Sendable @escaping () -> Void)
  public func invalidate()
  
  public static func registerAccess<Subject: Observable>(propertyKeyPath: PartialKeyPath<Subject>, subject: Subject)
  
  public static func withTracking(_ apply: () -> Void) -> ObservationTracking?
}

The ObservationTracking mechanism is the primary interface designed for the purposes to interoperate with SwiftUI. Views will register via the withTracking method such that if in the execution of body any field is accessed in an Observable that field is registered into the access set that will be indicated in the handler passed to the addChangeHandler function. If at any point in time that handler needs to be directly invalidated the invalidate function can be invoked; which will remove all change handlers registered to the Observable instances under the tracking.

SDK Impact (a preview of SwiftUI interaction)

Using existing systems like ObservableObject there are a number of edge cases that can be surprising unless developers really have an in-depth view to SwiftUI. Formalizing observation can make these edge cases considerably more approachable by reducing the complexity of the different systems needed to be understood.

The following is adapted from the Fruta sample app, modified for clarity:

final class Model: ObservableObject {
    @Published var order: Order?
    @Published var account: Account?
    
    var hasAccount: Bool {
        return userCredential != nil && account != nil
    }
    
    @Published var favoriteSmoothieIDs = Set<Smoothie.ID>()
    @Published var selectedSmoothieID: Smoothie.ID?
    
    @Published var searchString = ""
    
    @Published var isApplePayEnabled = true
    @Published var allRecipesUnlocked = false
    @Published var unlockAllRecipesProduct: Product?
}

struct SmoothieList: View {
    var smoothies: [Smoothie]
    @ObservedObject var model: Model
    
    var listedSmoothies: [Smoothie] {
        smoothies
            .filter { $0.matches(model.searchString) }
            .sorted(by: { $0.title.localizedCompare($1.title) == .orderedAscending })
    }
    
    var body: some View {
        List(listedSmoothies) { smoothie in
            ...
        }
    }
} 

The @Published identifies each field that participates to changes in the object, however it does not provide any differentiation for those changes. This means that from SwiftUI's perspective, a change to order effects things using hasAccount. This unfortunately means that there are additional layouts, rendering and updates created. The proposed API can not only reduce some of the @Published repetition but also simplify the SwiftUI view code too!

The previous example can then be written as:

final class Model: Observable {
    var order: Order?
    var account: Account?
    
    var hasAccount: Bool {
        return userCredential != nil && account != nil
    }
    
    var favoriteSmoothieIDs = Set<Smoothie.ID>()
    var selectedSmoothieID: Smoothie.ID?
    
    var searchString = ""
    
    var isApplePayEnabled = true
    var allRecipesUnlocked = false
    var unlockAllRecipesProduct: Product?
}

struct SmoothieList: View {
    var smoothies: [Smoothie]
    var model: Model
    
    var listedSmoothies: [Smoothie] {
        smoothies
            .filter { $0.matches(model.searchString) }
            .sorted(by: { $0.title.localizedCompare($1.title) == .orderedAscending })
    }
    
    var body: some View {
        List(listedSmoothies) { smoothie in
            ...
        }
    }
} 

The advantages of course don't stop at the only reading dependent property access. In the case for accessing searchString; if the value does not actually change - e.g. if it starts off as "blueb" and is then set again to "blueb" it is an equal value in the access so it does not fire a second time. Whereas ObservableObject today, does emit two events even if the value is the same; since it does not have any value in the objectWillChange publisher that information is lost. That information is intrinsically built in to Observable meaning that SwiftUI can utilize the .compareEquality option to ensure that duplicate updates do not impact performance.

There are some other interesting differences that come up; for example - tracking observation of access within a view can be applied to an Array, an Optional, or even a custom type. This opens up new and interesting ways developers can utilize SwiftUI more easily.

This is a potential future direction for SwiftUI but is not part of this proposal.

Source compatibility

This proposal is additive and provides no impact to existing source code.

Effect on ABI stability

This proposal is additive and no impact is made upon existing ABI stability. This does have implication to the inlinability and back-porting of this feature. In the cases where it is determined to be performance critical to the distribution of change events the methods will be marked as inlinable.

Effect on API resilience

This proposal is additive and no impact is made upon existing API resilience.

Location of API

It is an open question if this functionality should live at the standard library layer or in a package above it. There are considerations for each home for these APIs. If it were to live in the standard library (and Concurrency library) that would simplify some of the ABI requirements for interoperating with keypaths. The functionality is the logical progression of the fundamental language constructs of willSet/didSet. Putting this into a library above the standard library does maintain some level of isolation of functionality. The names picked for the types and protocols are relatively specific to the jobs they serve and pose only trivial impact of potential global namespace costs. If it is chosen that this lives in a package above the standard library; it only has minor operating system requirements but does require that there are particular additions to the key path types for handling splitting and checking if a key path has a given prefix.

extension PartialKeyPath {
  func hasPrefix(_ other: PartialKeyPath<Subject>) -> Bool
}

extension AnyKeyPath {
  struct Component: Equatable {
    var type: Any.Type
  }
  
  var components: [Component] { get }
  init?(components: [Component])
}

Future Directions & Default Implementation

A default implementation can be accomplished in generality by a type wrapper that intercepts the modifications of any field on the Observable . The type wrapper DefaultObservable provides default implementations for the type where the associated Observation type is ObservationTracking.Token. This means that developers have the flexibility of an easy to use interface that progressively allows for more and more detailed control: starting from mere annotation that grants general observability, progressing to delegation to a storage mechanism that manages registering and unregistering observers, to full control of observation.

@typeWrapper
public struct DefaultObservable<Subject: Observable, Storage> {
  public init(for subject: Subject.Type, storage: Storage)
  public subscript<Member>(
    wrappedSelf subject: Subject,
    propertyKeyPath property: KeyPath<Subject, Member>,
    storageKeyPath keyPath: KeyPath<Storage, Member>
  ) -> Member { get }
  public subscript<Member>(
    wrappedSelf subject: Subject,
    propertyKeyPath property: ReferenceWritableKeyPath<Subject, Member>,
    storageKeyPath keyPath: WritableKeyPath<Storage, Member>
  ) -> Member { get set }
}

@DefaultObservable
extension Observable where Observation == ObservationTracking.Token { 
  public func addObserver<Member>(_ observer: some Observer<Self, Member>, for keyPath: KeyPath<Self, Member>) -> Observation { ... }
  public func addChangeHandler(for fields: ObservationTracking.Fields<Self>, options: ObservationTracking.Options, _ handler: @Sendable @escaping () -> Void) -> Observation { ... }
  public func removeObservation(_ observation: Observation) { ... }
}

This definitely provides an attractive default experience such that developers do not need to implement their own access and observation tracking. However it does require a few minor modifications to the type wrapper proposal, namely of which; the application of a type wrapper on an extension of a protocol.

Further improvements might also include the ability to observe multiple properties with one observer, this would require a few external changes but internally the systems to observe should remain the same. This would allow for the future improvement to all Observable conforming types to gain the ability to add multiple change observations to produce an async sequence of changes.

for await change in someObject.changes(for: \.property1, \.property2) {
}

This requires some advanced features using variadic generics and is within the scope of considerations.

Alternatives considered

Under consideration for this API is to offer equivalents of keyPathsForValuesAffectingValueForKey: and automaticallyNotifiesObserversForKey:.

The observation APIs could be redesigned to return a value indicating automatic removal like change handlers. This requires developers to understand the implications of a boolean (or similar) return value, which may not be immediately obvious. Also this could be filed into options passed in with the observer.

The change handler APIs could instead of taking a closure could take an Observer that is required to have a signature of some Observer<Self, Self> in that it passes the keypath of \Self.self and self as the new value. Granted this reduces the surface area, it is quite strange of a syntax and is not immediately intuitive of an answer.

Acknowledgments

  • Holly Borla - For providing fantastic ideas on how to implement supporting infrastructure to this pitch
  • Pavel Yaskevich - For tirelessly iterating on prototypes for supporting compiler features
  • Rishi Verma - For bouncing ideas and helping with the design of integrating this idea into other work
  • Kyle Macomber - For connecting resources and providing useful feedback
  • Matt Ricketson - For helping highlight some of the inner guts of SwiftUI

Related systems

Edits:

Updated attributions

85 Likes

This is nice thinking, and at first blush seems like a great improvement on KVO’s design.

It does not seem to me like something that should be in the standard library, and I’m skeptical of whether it should be anything other than a standalone library. One of the problems with KVO was that it was both too much and too little for every problem: the observer pattern works best when it’s tailored to the nuances of the problem at hand, and “objects with properties that change” is not always the best abstraction for an observer pattern.

In my own efforts with Siesta, for example, I found it best to use a state machine model for the observation: a known set of completely domain-specific state transitions circumscribe what can and cannot have happened with each change event, in ways that closely mirror common patterns in observer code, not the raw structure of the observable thing itself.

This looks to me like it could be a lovely standalone library for people specifically looking for KVO / Java-Bean-like property-by-property change notifications! I’m fairly skeptical of attempting to standardize the notion of “observable” around it for all of Swift.

11 Likes

I'm very happy to see the language filling those gaps in concurrency solutions so we can avoid using other frameworks.

  • I have a question, since is based on KeyPath, I assume the \.self keyath will work fine to observe the entire object? object.changes(for: \.self)

On the detailed design I have a hard time understanding what's a "changeHandler" and what do you need to implement on your type.

For cases where developers want to offer change events directly but do not want to manage the list of observers themselves a default storage type is included.

Is this talking only about the fact that the ObservationList type is availalble, or is it also saying that thanks to some compiler magic the storage for the observers is handled automatically? In other words, do we have to keep the list of observers manually ourselves on our type, or is kinda "magic" like in SwiftUI?

The ObservationList type is specifically crafted to allow for composition of observation with other systems.

I still need to grasp what this means but I wonder if this is a potential solution for architectural composability in SwiftUI apps. Anybody that has used Pointfree's TCA would have enjoyed how composing domains comes for free. When going back to plain ViewModels/ObservableObject it becomes a bit pain to break them down since then you have to manage communication between them manually. I wonder if this "observability composition" mentioned is an actual solution for this.

Overall super exciting stuff, I do feel I need to read the pitch a couple more times ^^

1 Like

This is effectively the change handler, but I am not fully sure that ergonomically works. It is definitely a part I am interested in getting feedback if it is a reasonable modification or not.

That part in particular does that changes(for: \.self)

To be quite clear: this is a replacement of the role of ObservableObject.

As it stands: I have a prototype that works as a standalone project, however it definitely has some parts that feel standard library territory (parts of it need to be built with -parse-stdlib to build up some of the required manipulation with key paths). There are compelling arguments for both a stand-alone package, and a builtin system; this is a topic I feel is best discussed in details of why it belongs in each location than say a dictate that it must belong in a specific location.

1 Like

Will definitely return to this thread when I have more time to focus. I quickly scanned a few bits and here's my first question / concern.

I personally use ObservableObject and still have internal stored properties on that object which are used for example for caching things and signal objectWillChange at a later point of time. It seems like conforming to Observable will straight make every stored property ping a change to SwiftUI. This and marking those properties as private to avoid it is a no go for me. If anything there must be a different attribute that would exclude those properties from observation, but not private. I generally never use private anymore.

6 Likes

Only ones that compose to a read property. If within the execution of body a property is never read, then it has no effect to the updates/re-render of views.

3 Likes

It's not clear from reading the proposal if/how this is intended to work for computed properties. For example, I've written an observable of the form

final class ExampleObservable: ObservableObject {
    @Published private var minutes = 1
    @Published private var seconds = 0

    var time: String {
        String(format: "%.2ld:%.2ld", self.minutes, self.seconds)
    }
}

Would it be possible to observe \.time? If so, this brings in a lot of questions about side-effectful getters. If not, that precludes this from a lot of use-cases without something much more convoluted like

final class ExampleObservable: Observable {
    private var minutes = 1 {
        didSet {
             time = String(format: "%.2ld:%.2ld", self.minutes, self.seconds)
        }
    }
    private var seconds = 0 {
        didSet {
             time = String(format: "%.2ld:%.2ld", self.minutes, self.seconds)
        }
    }

    private(set) var time = "01:00"
}
1 Like

Given a swiftUI view:

final class ExampleObservable: Observable {
    private var minutes = 1
    private var seconds = 0

    var time: String {
        String(format: "%.2ld:%.2ld", self.minutes, self.seconds)
    }
}

struct ExampleView: View {
  var example = ExampleObservable()
  var body: some View {
    Text(example.time)
    Button("Test Update") {
      example.minutes = 3
    }
  }
}

That will just work for the update.

I'm sorry but I'm not sure I follow. A small visual example might help.

class Model: Observable {
  var name: String = "swift"
  
  // private in my case
  var _someState: Bool = false

  func doSomething() {
    ...
    _someState = true
  }
}

struct MyView: View {
  let model: Model

  var body: some View {
     Button(model.name) {
       model.doSomething() // will this cause a new update cycle??
     }
  }
}

Btw. no more ObsevedObject? How would that work?

1 Like

In that example, during body's execution the only field accessed is name so the doSomething() call does not modify that and will not cause an update to the view.

I posed a sample bit of code in the pitch, basically the long/short is that all you need to do is mark something as Observable and SwiftUI will know to interrogate that because of the ObservationTracking.withTracking call. That returns a tracking instance that knows all fields accessed in the scope of the applied closure.

2 Likes

I would love to undestand the full details of this eventually. It is a bit magical for me atm.

Forgive me, I haven't reached that far during my quick scan. What about the lazy @StateObject though? Will this also be replaced, if so, can ObservationTracking cover this and also keep the lazy initialization behavior?

Some of those details are still in flux and under active development with the SwiftUI folks. But the initial thought is that all @*Object property wrappers will be able to be used as their non-object counterparts - i.e. where you used @StateObject before just @State etc.

Obviously since that is still under development to see if that works or not I don't have precise details on exactly how that will pan out. But the intent is to allow for when Observable is used developers need to consider fewer potential property wrappers and in the case where no lifetime specialities or bindings are needed and just plain observations are desired, no property wrapper is required.

The take-away here is that the observability portions will all be part of a cross platform and open package/addition (depending on where the homing puts it). So hopefully some of that magic can be dispelled a bit to a simple interface.

5 Likes

One more thing. I cannot find anything about nested observations. For example one ObservableObject can store another ObservableObject and just forward specific events up. Is it just me again, because I fail to see how I could ping self to indicate and bubble up a change from a nested observation?

Are you asking about change handlers for SwiftUI or observations for specific keypath?

Perhaps I should add some examples around this; nested observables will forward up. But the answer is yes:

1 Like

I have something like this in mind:

class Model: ObservableObject {
  let _coreDataObject: CoreDataObject
  var _subscriptions: Set<AnyCancellable>

  // read by SwiftUI
  var name: String? {
    _coreDataObject.name
  }

  init(coreDataObject: CoreDataObject) {
    self._coreDataObject = coreDataObject
    self._subscriptions = []

    // post init

    // Prepare the publisher to use without catching `self`.
    let publisher = objectWillChange

    // Establish new KVO subscriptions.
    Publishers.MergeMany
      .init(
        // Grab `name` KVO updates and transform those into Void events.
        self._coreDataObject.publisher(for: \.name).map { _ in }.eraseToAnyPublisher(),
        ...
      )
      .sink(receiveValue: publisher.send)
      .store(in: &_subscriptions)
  }
}

I do ping objectWillChange for my Model manually when I detect that there was some kind of a change in a property that I'm interested in on my nested observable object.

For building something like that an ObservationList is where you would likely start, I could imagine that specialized protocols and type wrappers could be formed on top of Observable to make that more automatic. But nesting observations will still work if you have a type backed with an ObservationList or heck even custom observation handling.

The key path accessor is fired via (on ObservationTracking):

public static func registerAccess<Subject: Observable>(propertyKeyPath: PartialKeyPath<Subject>, subject: Subject)

That lets the tracking know about the access to a given field.

And the ObservationList can inform changes to observers and change handlers via the setMember method.

1 Like

Thank you for all your replies, I need to digest the pitch and the details you shared in your messages before I can provide further feedback. :slight_smile:


It would be cool if some folks from the SwiftUI team would jump into the conversation so we can shape the direction of changes a bit more publicly.

I have spoken @luca_bernardi @ricketson @kylemacomber at length about these changes before posting here, but definitely welcome more engagement.

3 Likes

What is associatedtype Observation and how is it inferred? Btw. if there's a toolchain available for this, it might be helpful if we could play around with these APIs already.

1 Like

The Observation is the registration (or token) representing the observer. It can be custom per each conformance of Observation; by default it is ObservationTracking.Token which is an Int under the hood.

I have a package that I need to clean up for viewing that will be posted later. There are a few tasks I have on my plate for that before it is fully ready to share. As it stands it needs some additional alterations to type wrappers to make it over the line.

3 Likes