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

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.

Leave a Comment

Hata!: SQLSTATE[HY000] [1045] Access denied for user 'divattrend_liink'@'localhost' (using password: YES)