Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

In SwiftUI, how to react to changes on "@Published vars" *outside* of a "View"

Tags:

Suppose I have the following ObservableObject, which generates a random String every second:

import SwiftUI  class SomeObservable: ObservableObject {      @Published var information: String = ""      init() {         Timer.scheduledTimer(             timeInterval: 1.0,             target: self,             selector: #selector(updateInformation),             userInfo: nil,             repeats: true         ).fire()     }      @objc func updateInformation() {         information = String("RANDOM_INFO".shuffled().prefix(5))     } } 

And a View, which observes that:

struct SomeView: View {      @ObservedObject var observable: SomeObservable      var body: some View {         Text(observable.information)     } } 

The above will work as expected.
The View redraws itself when the ObservableObject changes:

viewUpdate

Now for the question:

How could I do the same (say calling a function) in a "pure" struct that also observes the same ObservableObject? By "pure" I mean something that does not conform to View:

struct SomeStruct {      @ObservedObject var observable: SomeObservable      // How to call this function when "observable" changes?     func doSomethingWhenObservableChanges() {         print("Triggered!")     } } 

(It could also be a class, as long as it's able to react to the changes on the observable.)

It seems to be conceptually very easy, but I'm clearly missing something.

(Note: I'm using Xcode 11, beta 6.)


Update (for future readers) (paste in a Playground)

Here is a possible solution, based on the awesome answer provided by @Fabian:

import SwiftUI import Combine import PlaygroundSupport  class SomeObservable: ObservableObject {      @Published var information: String = "" // Will be automagically consumed by `Views`.      let updatePublisher = PassthroughSubject<Void, Never>() // Can be consumed by other classes / objects.      // Added here only to test the whole thing.     var someObserverClass: SomeObserverClass?      init() {         // Randomly change the information each second.         Timer.scheduledTimer(             timeInterval: 1.0,             target: self,             selector: #selector(updateInformation),             userInfo: nil,             repeats: true         ).fire()    }      @objc func updateInformation() {         // For testing purposes only.         if someObserverClass == nil { someObserverClass = SomeObserverClass(observable: self) }          // `Views` will detect this right away.         information = String("RANDOM_INFO".shuffled().prefix(5))          // "Manually" sending updates, so other classes / objects can be notified.         updatePublisher.send()     } }  class SomeObserverClass {      @ObservedObject var observable: SomeObservable      // More on AnyCancellable on: apple-reference-documentation://hs-NDfw7su     var cancellable: AnyCancellable?      init(observable: SomeObservable) {         self.observable = observable          // `sink`: Attaches a subscriber with closure-based behavior.         cancellable = observable.updatePublisher             .print() // Prints all publishing events.             .sink(receiveValue: { [weak self] _ in             guard let self = self else { return }             self.doSomethingWhenObservableChanges()         })     }      func doSomethingWhenObservableChanges() {         print(observable.information)     } }  let observable = SomeObservable()  struct SomeObserverView: View {     @ObservedObject var observable: SomeObservable     var body: some View {         Text(observable.information)     } } PlaygroundPage.current.setLiveView(SomeObserverView(observable: observable)) 

Result

result

(Note: it's necessary to run the app in order to check the console output.)

like image 552
backslash-f Avatar asked Aug 22 '19 10:08

backslash-f


People also ask

How can an observable object announce changes to SwiftUI?

Classes that conform to the ObservableObject protocol can use SwiftUI's @Published property wrapper to automatically announce changes to properties, so that any views using the object get their body property reinvoked and stay in sync with their data.

What is @published SwiftUI?

@Published is one of the property wrappers in SwiftUI that allows us to trigger a view redraw whenever changes occur. You can use the wrapper combined with the ObservableObject protocol, but you can also use it within regular classes.

What is ObservableObject SwiftUI?

A type of object with a publisher that emits before the object has changed.


1 Answers

The old way was to use callbacks which you registered. The newer method is to use the Combine framework to create publishers for which you can registers further processing, or in this case a sink which gets called every time the source publisher sends a message. The publisher here sends nothing and so is of type <Void, Never>.

Timer publisher

To get a publisher from a timer can be done directly through Combine or creating a generic publisher through PassthroughSubject<Void, Never>(), registering for messages and sending them in the timer-callback via publisher.send(). The example has both variants.

ObjectWillChange Publisher

Every ObservableObject does have an .objectWillChange publisher for which you can register a sink the same as you do for Timer publishers. It should get called every time you call it or every time a @Published variable changes. Note however, that is being called before, and not after the change. (DispatchQueue.main.async{} inside the sink to react after the change is complete).

Registering

Every sink call creates an AnyCancellable which has to be stored, usually in the object with the same lifetime the sink should have. Once the cancellable is deconstructed (or .cancel() on it is called) the sink does not get called again.

import SwiftUI import Combine  struct ReceiveOutsideView: View {     #if swift(>=5.3)         @StateObject var observable: SomeObservable = SomeObservable()     #else         @ObservedObject var observable: SomeObservable = SomeObservable()     #endif      var body: some View {         Text(observable.information)             .onReceive(observable.publisher) {                 print("Updated from Timer.publish")         }         .onReceive(observable.updatePublisher) {             print("Updated from updateInformation()")         }     } }  class SomeObservable: ObservableObject {          @Published var information: String = ""          var publisher: AnyPublisher<Void, Never>! = nil          init() {                  publisher = Timer.publish(every: 1.0, on: RunLoop.main, in: .common).autoconnect().map{_ in             print("Updating information")             //self.information = String("RANDOM_INFO".shuffled().prefix(5))         }.eraseToAnyPublisher()                  Timer.scheduledTimer(             timeInterval: 1.0,             target: self,             selector: #selector(updateInformation),             userInfo: nil,             repeats: true         ).fire()     }          let updatePublisher = PassthroughSubject<Void, Never>()          @objc func updateInformation() {         information = String("RANDOM_INFO".shuffled().prefix(5))         updatePublisher.send()     } }  class SomeClass {          @ObservedObject var observable: SomeObservable          var cancellable: AnyCancellable?          init(observable: SomeObservable) {         self.observable = observable                  cancellable = observable.publisher.sink{ [weak self] in             guard let self = self else {                 return             }                          self.doSomethingWhenObservableChanges() // Must be a class to access self here.         }     }          // How to call this function when "observable" changes?     func doSomethingWhenObservableChanges() {         print("Triggered!")     } } 

Note here that if no sink or receiver at the end of the pipeline is registered, the value will be lost. For example creating PassthroughSubject<T, Never>, immediately sending a value and aftererwards returning the publisher makes the messages sent get lost, despite you registering a sink on that subject afterwards. The usual workaround is to wrap the subject creation and message sending inside a Deferred {} block, which only creates everything within, once a sink got registered.

A commenter notes that ReceiveOutsideView.observable is owned by ReceiveOutsideView, because observable is created inside and directly assigned. On reinitialization a new instance of observable will be created. This can be prevented by use of @StateObject instead of @ObservableObject in this instance.

like image 58
Fabian Avatar answered Oct 12 '22 17:10

Fabian