HomeiOS DevelopmentObserving properties on an @Observable class outdoors of SwiftUI views – Donny...

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


Revealed on: January 21, 2025

On iOS 17 and newer, you’ve entry to the Observable macro. This macro will be utilized to lessons, and it permits SwiftUI to formally observe properties on an observable class. If you wish to study extra about Observable or when you’re searching for an introduction, undoubtedly go forward and take a look at my introduction to @Observable in SwiftUI.

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

A easy statement instance

The Observable macro was constructed to lean right into a perform referred to as WithObservationTracking. The WithObservationTracking perform means that you can entry state in your observable. The observable will then observe the properties that you have accessed within that closure. If any of the properties that you have tried to entry change, there is a closure that will get referred to 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 way in which statement works is that any properties that I entry within that first closure will probably be marked as properties that I am excited about. So if any of these properties change, on this case there’s just one, the onChange closure will probably be referred to 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 may cause points

Whereas this seems to be easy sufficient, there are literally a couple of irritating hiccups to cope with whenever you work with statement 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 named, 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 excited about, after which have my onChange hearth 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, when you’re observing a String and also you wish to carry out a search motion when the textual content adjustments, you’ll try this within 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 named with willSet semantics.

Which means that the onChange closure is named earlier than the properties you’re excited about 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 would work round this by calling observe from a name to DispatchQueue.primary.async.

Getting didSet semantics when utilizing withObservationTracking

Since onChange is named earlier than the properties we’re excited about have up to date we have to postpone our work to the following runloop if we wish to get entry to new values. A standard approach to do that is through the use of DispatchQueue.primary.async:

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

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

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

The utilization of this perform within 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 are able to now go a single closure to withObservationTracking. Any properties that we have accessed within that closure are actually routinely noticed for adjustments, and our closure will preserve working each time one among these properties change. As a result of we’re capturing self weakly and we solely entry any properties when self continues to be round, we additionally help some type of cancellation.

Notice that my strategy is slightly 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, when you attempt to use this within 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 implies we are able to’t seize non-Sendable state (learn this publish for an in-depth clarification of that error). We will’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 quite a lot 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 principle actor. Typically this isn’t a nasty concept in any respect, however once we try 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 }
      // Predominant actor-isolated property 'counter' can't be referenced from a Sendable closure
      print("counter.depend: (counter.depend)")
    }
  }
}

We will make our code compile by wrapping entry in a Process that additionally runs on the principle 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 are able to’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 unbelievable for SwiftUI apps, there’s quite a lot of work to be completed for it to be usable from different locations. Total I feel Mix’s publishers (and @Revealed specifically) present a extra usable strategy to subscribe to adjustments on a particular property; particularly whenever you wish 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 mild on the problems you may encounter (and how one can work round them).

In case you’re utilizing withObservationTracking efficiently in a Swift 6 app or package deal, I’d like to hear from you.

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

Recent Comments