Observing properties on an @Observable class exterior of SwiftUI views – Donny Wals

Observing properties on an @Observable class exterior of SwiftUI views – Donny Wals


Revealed on: January 21, 2025

On iOS 17 and newer, you’ve got entry to the Observable macro. This macro will be utilized to courses, and it permits SwiftUI to formally observe properties on an observable class. If you wish to be taught extra about Observable or if you happen to’re on the lookout for an introduction, positively go forward and take a look at my introduction to @Observable in SwiftUI.

On this publish, I wish to discover how one can observe properties on an observable class. Whereas the ObservableObject protocol allowed us to simply observe revealed properties, we do not have one thing like that with Observable. Nevertheless, that does not imply we can’t observe observable properties.

A easy commentary instance

The Observable macro was constructed to lean right into a perform known as WithObservationTracking. The WithObservationTracking perform lets you entry state in your observable. The observable will then observe the properties that you have accessed inside that closure. If any of the properties that you have tried to entry change, there is a closure that will get known as. Here is what that appears like.

@Observable
class Counter {
  var depend = 0
}

class CounterObserver {
  let counter: Counter

  init(counter: Counter) {
    self.counter = counter
  }

  func observe() {
    withObservationTracking { 
      print("counter.depend: (counter.depend)")
    } onChange: {
      self.observe()
    }
  }
}

Within the observe perform that’s outlined on CounterObserver, I entry a property on the counter object.

The best way commentary works is that any properties that I entry inside that first closure will probably be marked as properties that I am considering. So if any of these properties change, on this case there’s just one, the onChange closure will probably be known as to tell you that there have been adjustments made to a number of properties that you have accessed within the first closure.

How withObservationTracking could cause points

Whereas this seems to be easy sufficient, there are literally a number of irritating hiccups to cope with if you work with commentary monitoring. Notice that in my onChange I name self.observe().

It is because withObservationTracking solely calls the onChange closure as soon as. So as soon as the closure is known as, you don’t get notified about any new updates. So I have to name observe once more to as soon as extra entry properties that I am considering, after which have my onChange fireplace once more when the properties change.

The sample right here basically is to utilize the state you’re observing in that first closure.

For instance, if you happen to’re observing a String and also you need to carry out a search motion when the textual content adjustments, you’ll try this inside withObservationTracking‘s first closure. Then when adjustments happen, you possibly can re-subscribe from the onChange closure.

Whereas all of this isn’t nice, the worst half is that onChange is known as with willSet semantics.

Which means that the onChange closure is known as earlier than the properties you’re considering have modified so you are going to at all times have entry to the previous worth of a property and never the brand new one.

You might work round this by calling observe from a name to DispatchQueue.fundamental.async.

Getting didSet semantics when utilizing withObservationTracking

Since onChange is known as earlier than the properties we’re considering have up to date we have to postpone our work to the following runloop if we need to get entry to new values. A standard method to do that is by utilizing DispatchQueue.fundamental.async:

func observe() {
  withObservationTracking { 
    print("counter.depend: (counter.depend)")
  } onChange: {
    DispatchQueue.fundamental.async {
      self.observe()
    }
  }
}

The above isn’t fairly, however it works. Utilizing an method based mostly on what’s proven right here on the Swift boards, we will transfer this code right into a helper perform to cut back boilerplate:

public func withObservationTracking(execute: @Sendable @escaping () -> Void) {
    Statement.withObservationTracking {
        execute()
    } onChange: {
        DispatchQueue.fundamental.async {
            withObservationTracking(execute: execute)
        }
    }
}

The utilization of this perform inside observe() would look as follows:

func observe() {
  withObservationTracking { [weak self] in
    guard let self else { return }
    print("counter.depend: (counter.depend)")
  }
}

With this straightforward wrapper that we wrote, we will now go a single closure to withObservationTracking. Any properties that we have accessed inside that closure at the moment are routinely noticed for adjustments, and our closure will preserve operating each time considered one of these properties change. As a result of we’re capturing self weakly and we solely entry any properties when self remains to be round, we additionally help some type of cancellation.

Notice that my method is reasonably completely different from what’s proven on the Swift boards. It is impressed by what’s proven there, however the implementation proven on the discussion board really would not help any type of cancellation. I figured that including a bit of little bit of help for cancellation was higher than including no help in any respect.

Statement and Swift 6

Whereas the above works fairly first rate for Swift 5 packages, if you happen to attempt to use this inside a Swift 6 codebase, you may really run into some points… As quickly as you activate the Swift 6 language mode you’ll discover the next error:

func observe() {
  withObservationTracking { [weak self] in
    guard let self else { return }
    // Seize of 'self' with non-sendable kind 'CounterObserver?' in a `@Sendable` closure
    print("counter.depend: (counter.depend)")
  }
}

The error message you’re seeing right here tells you that withObservationTracking desires us to go an @Sendable closure which suggests we will’t seize non-Sendable state (learn this publish for an in-depth clarification of that error). We are able to’t change the closure to be non-Sendable as a result of we’re utilizing it within the onChange closure of the official withObservationTracking and as you might need guessed; onChange requires our closure to be sendable.

In a variety of circumstances we’re capable of make self Sendable by annotating it with @MainActor so the thing at all times runs its property entry and features on the primary actor. Typically this isn’t a nasty concept in any respect, however once we attempt to apply it on our instance we obtain the next error:

@MainActor
class CounterObserver {
  let counter: Counter

  init(counter: Counter) {
    self.counter = counter
  }

  func observe() {
    withObservationTracking { [weak self] in
      guard let self else { return }
      // Major actor-isolated property 'counter' cannot be referenced from a Sendable closure
      print("counter.depend: (counter.depend)")
    }
  }
}

We are able to make our code compile by wrapping entry in a Process that additionally runs on the primary actor however the results of doing that’s that we’d asynchronously entry our counter and we’ll drop incoming occasions.

Sadly, I haven’t discovered an answer to utilizing Statement with Swift 6 on this method with out leveraging @unchecked Sendable since we will’t make CounterObserver conform to Sendable because the @Observable class we’re accessing can’t be made Sendable itself (it has mutable state).

In Abstract

Whereas Statement works incredible for SwiftUI apps, there’s a variety of work to be performed for it to be usable from different locations. General I believe Mix’s publishers (and @Revealed particularly) present a extra usable strategy to subscribe to adjustments on a particular property; particularly if you need to use the Swift 6 language mode.

I hope this publish has proven you some choices for utilizing Statement, and that it has shed some gentle on the problems you may encounter (and how one can work round them).

In the event you’re utilizing withObservationTracking efficiently in a Swift 6 app or bundle, I’d like to hear from you.

Leave a Reply

Your email address will not be published. Required fields are marked *