-10.6 C
New York
Tuesday, January 21, 2025

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


Printed on: January 21, 2025

On iOS 17 and newer, you’ve got entry to the Observable macro. This macro could be utilized to courses, and it permits SwiftUI to formally observe properties on an observable class. If you wish to study extra about Observable or in case you’re in search of an introduction, undoubtedly 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 not observe observable properties.

A easy statement instance

The Observable macro was constructed to lean right into a operate referred to as WithObservationTracking. The WithObservationTracking operate permits you to 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 referred to as. Here is what that appears like.

@Observable
class Counter {
  var rely = 0
}

class CounterObserver {
  let counter: Counter

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

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

Within the observe operate 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 inside that first closure will likely 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 likely 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 easy sufficient, there are literally just a few irritating hiccups to take care of while you work with statement monitoring. Word that in my onChange I name self.observe().

It’s 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 primarily is to utilize the state you’re observing in that first closure.

For instance, in case you’re observing a String and also you wish to carry out a search motion when the textual content adjustments, you’d do this inside withObservationTracking‘s first closure. Then when adjustments happen, you may 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.

Because of this 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 outdated worth of a property and never the brand new one.

You may work round this by calling observe from a name to DispatchQueue.primary.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 subsequent runloop if we wish to get entry to new values. A standard means to do that is through the use of DispatchQueue.primary.async:

func observe() {
  withObservationTracking { 
    print("counter.rely: (counter.rely)")
  } onChange: {
    DispatchQueue.primary.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 operate to scale back boilerplate:

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

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

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

With this straightforward wrapper that we wrote, we will now move 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 one in every 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.

Word that my method is quite 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 somewhat little bit of help for cancellation was higher than including no help in any respect.

Remark and Swift 6

Whereas the above works fairly respectable for Swift 5 packages, in case you attempt to use this inside a Swift 6 codebase, you will 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.rely: (counter.rely)")
  }
}

The error message you’re seeing right here tells you that withObservationTracking needs us to move 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 whole lot of instances we’re capable of make self Sendable by annotating it with @MainActor so the article at all times runs its property entry and capabilities 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 }
      // Predominant actor-isolated property 'counter' can't be referenced from a Sendable closure
      print("counter.rely: (counter.rely)")
    }
  }
}

We are able to make our code compile by wrapping entry in a Job 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 Remark with Swift 6 on this method with out leveraging @unchecked Sendable since we will’t make CounterObserver conform to Sendable for the reason that @Observable class we’re accessing can’t be made Sendable itself (it has mutable state).

In Abstract

Whereas Remark works implausible for SwiftUI apps, there’s a whole lot of work to be carried out for it to be usable from different locations. General I believe Mix’s publishers (and @Printed particularly) present a extra usable method to subscribe to adjustments on a particular property; particularly while you wish to use the Swift 6 language mode.

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

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

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles