Reputation: 8193
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.)
Upvotes: 38
Views: 22297
Reputation: 5348
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.
Upvotes: 29