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:
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.)
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
(Note: it's necessary to run the app in order to check the console output.)
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.
@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.
A type of object with a publisher that emits before the object has changed.
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>
.
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.
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).
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With