Search code examples
swiftreactive-programmingcombinelatestcombine

Triggering CombineLatest to propagate initial value in Combine


I've got to two String publishers and one computed property which returns AnyPublisher. Logic is quite simple but I would like to know if there is any way to propagate initial value. I think it should be somehow possible since publishers have initial values.

In VC I'm assigning new values to Publishers from ViewModel (from textField).

firstTextField.addTarget(self, action: #selector(firstTextFieldDidChange(_:)), for: .editingChanged)
secondTextField.addTarget(self, action: #selector(secondTextFieldDidChange(_:)), for: .editingChanged)

@objc private func firstTextFieldDidChange(_ textField: UITextField) {
 viewModel.firstPublisher = textField.text ?? ""
}
@objc private func secondTextFieldDidChange(_ textField: UITextField) {
 viewModel.secondPublisher = textField.text ?? ""
}

And then I'm assigning Publisher (combineLatest) to my button:

_ = viewModel.validatedText
   .receive(on: RunLoop.main)
   .assign(to: \.isEnabled, on: button)

In VM I've got two Publishers:

@Published var firstPublisher: String = ""
@Published var secondPublisher: String = ""

and CombineLatest:

var validatedText: AnyPublisher<Bool, Never> {
    return Publishers.CombineLatest($firstPublisher, $secondPublisher) {
        return !($0.isEmpty || $1.isEmpty)
        }.eraseToAnyPublisher()
}

validatedText only starts publishing new values when I start typing in both text fields. I tried assigning some new values in init of VM for example (to first and second Publisher) but it also didn't work. Is there any way to do it or I will have to set initial state of button (disable it) without using combine?


Solution

  • Unfortunately, it seems like this just may be the behavior of @Published, but you can work around this in your generated Publisher by prepending an initial value:

    var validatedText: AnyPublisher<Bool, Never> {
        let validate: (String, String) -> Bool = {
            !($0.isEmpty || $1.isEmpty)
        }
        return Publishers.CombineLatest($firstPublisher, $secondPublisher, transform: validate)
            .prepend(validate(firstPublisher, secondPublisher))
            .eraseToAnyPublisher()
    }
    

    Conversely, it is fairly trivial to write your own property delegate to get the behavior you want if you'd rather take that approach:

    import Combine
    
    @propertyDelegate
    struct InitialPublished<Value> : Publisher {
        typealias Output = Value
        typealias Failure = Never
    
        private let subject: CurrentValueSubject<Output, Failure>
    
        var value: Value {
            set { subject.value = newValue }
            get { subject.value }
        }
    
        init(initialValue: Value) {
            subject = CurrentValueSubject(initialValue)
        }
    
        func receive<S>(subscriber: S) where S: Subscriber, Value == S.Input, Failure == S.Failure {
            subject.receive(subscriber: subscriber)
        }
    }