8

I'm working on an iOS application adopting the MVVM pattern, using SwiftUI for designing the Views and Swift Combine in order to glue together my Views with their respective ViewModels. In one of my ViewModels I've created a Publisher (type Void) for a button press and another one for the content of a TextField (type String). I want to be able to combine both Publishers within my ViewModel in a way that the combined Publisher only emits events when the button Publisher emits an event while taking the latest event from the String publisher, so I can do some kind of evaluation on the TextField data, every time the user pressed the button. So my VM looks like this:

import Combine
import Foundation

public class MyViewModel: ObservableObject {
    @Published var textFieldContent: String? = nil
    @Published var buttonPressed: ()

    init() {
        // Combine `$textFieldContent` and `$buttonPressed` for evaulation of textFieldContent upon every button press... 
    }
}

Both publishers are being pupulated with data by SwiftUI, so i will omit that part and let's just assume both publishers receive some data over time.

Coming from the RxSwift Framework, my goto solution would have been the withLatestFrom operator to combine both observables. Diving into the Apple Documentation of Publisher in the section "Combining Elements from Multiple Publishers" however, I cannot find something similar, so I expect this kind of operator to be missing currently.

So my question: Is it possible to use the existing operator-API of the Combine Framework to get the same behavior in the end like withLatestFrom?

Alienbash
  • 554
  • 7
  • 17
  • I don't know RxSwift so I don't know what `withLatestFrom` does. What's the desired functionality? You probably want either `combineLatest` or `zip`, but it depends on what you want to do. See http://www.apeth.com/UnderstandingCombine/operators/operatorsJoiners/operatorscombinelatest.html If neither of those works as desired out of the box, please revise the question to say what's problematic with them and we can add an operator that fixes it. – matt May 22 '20 at 16:35
  • Unfortunately neither `combineLatest`, nor `zip` do fullfill my requirements here, because `withLatestFrom` makes sure that the resulting stream only emits events if the first stream (the button clicks in my example) emits events and taking the latest event from the second stream. `combineLatest` emits events upon every latest emission of every stream and zip waits for both stream to emit new events. – Alienbash May 22 '20 at 16:40
  • 1
    According to [this](https://github.com/CombineCommunity/rxswift-to-combine-cheatsheet) there is no equivalent. – Sweeper May 22 '20 at 16:40
  • @sweeper thanks for the source, that's helpful. I was thinking that maybe it's possible to combine existing operators for the same behavior. Since I was using `withLatestFrom` a lot lately, I'm pretty sure this would be of great benefit for a lot of Combine Users. – Alienbash May 22 '20 at 16:42
  • Right, thanks for the clarification. No worries! What I do is append another operator to eliminate "duplicates" where a duplicate is defined as the problematic member of the pair not having changed. That way, only changes come down the pipeline. If you are going to be doing this a lot, it's easy to make a custom operator that expresses a combination like that. – matt May 22 '20 at 16:50
  • Would you mind posting a suggestion for a combination like that as an official answer? It would be of great help, thank you. Not 100% sure, if I get your suggestion right, but it wouldn't be sufficient to only emit distinct elements, because the String publisher (representing the `TextField`) emits a new event every time the user enters something and that should not emit a new event in the resulting stream. – Alienbash May 22 '20 at 16:54
  • Understood, so it's the _other_ publisher that needs to be a non-duplicate. That's my point. Is that right? We would need your button publisher to pass something distinct (a timestamp would do) so that we can say "yes, this is new" and let it pass thru together with the latest value from the text publisher. That's easy to do. – matt May 22 '20 at 17:08
  • Right, but the other publisher (e.g. the `Void` button click event) being a duplicate or not is secondary. The main issue is for the resulting stream to only emit, if one(!) of the publishers emits an event (be it duplicate or not) and only then taking the latest event from the other publisher. A [RxMarble diagram](https://rxmarbles.com/#withLatestFrom) probably says more than 1000 words ;) – Alienbash May 22 '20 at 17:12
  • It's not secondary because that's the technique we are going to use to know when to pass the value. If you don't want to use this technique, fine, but it works. I mean, once you accept there is no built in operator that does this, either you want to know how to do it instead or you don't. And what I'm describing is a way to do it. – matt May 22 '20 at 17:21
  • Alright, then how about you post a concrete solution and if it‘s the desired behavior in the end, I’m gonna accept the answer. – Alienbash May 22 '20 at 17:24

3 Answers3

10

It sounds great to have a built-in operator for this, but you can construct the same behavior out of the operators you've got, and if this is something you do often, it's easy to make a custom operator out of existing operators.

The idea in this situation would be to use combineLatest along with an operator such as removeDuplicates that prevents a value from passing down the pipeline unless the button has emitted a new value. For example (this is just a test in the playground):

var storage = Set<AnyCancellable>()
var button = PassthroughSubject<Void, Never>()
func pressTheButton() { button.send() }
var text = PassthroughSubject<String, Never>()
var textValue = ""
let letters = (97...122).map({String(UnicodeScalar($0))})
func typeSomeText() { textValue += letters.randomElement()!; text.send(textValue)}

button.map {_ in Date()}.combineLatest(text)
    .removeDuplicates {
        $0.0 == $1.0
    }
    .map {$0.1}
    .sink { print($0)}.store(in:&storage)

typeSomeText()
typeSomeText()
typeSomeText()
pressTheButton()
typeSomeText()
typeSomeText()
pressTheButton()

The output is two random strings such as "zed" and "zedaf". The point is that text is being sent down the pipeline every time we call typeSomeText, but we don't receive the text at the end of the pipeline unless we call pressTheButton.

That seems to be the sort of thing you're after.


You'll notice that I'm completely ignoring what the value sent by the button is. (In my example it's just a void anyway.) If that value is important, then change the initial map to include that value as part of a tuple, and strip out the Date part of the tuple afterward:

button.map {value in (value:value, date:Date())}.combineLatest(text)
    .removeDuplicates {
        $0.0.date == $1.0.date
    }
    .map {($0.value, $1)}
    .map {$0.1}
    .sink { print($0)}.store(in:&storage)

The point here is that what arrives after the line .map {($0.value, $1)} is exactly like what withLatestFrom would produce: a tuple of both publishers' most recent values.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • 2
    This is not excat `withLatestFrom` https://rxjs.dev/api/operators/withLatestFrom. WithLatestFrom should emit events `only when the source emits` ---- ``` pressTheButton() typeSomeText() ``` This code prints character but shouldn't – Josshad May 06 '21 at 15:45
1

As improvement of @matt answer this is more convenient withLatestFrom, that fires on same event in original stream

Updated: Fix issue with combineLatest in iOS versions prior to 14.5

extension Publisher {
  func withLatestFrom<P>(
    _ other: P
  ) -> AnyPublisher<(Self.Output, P.Output), Failure> where P: Publisher, Self.Failure == P.Failure {
    let other = other
      // Note: Do not use `.map(Optional.some)` and `.prepend(nil)`.
      // There is a bug in iOS versions prior 14.5 in `.combineLatest`. If P.Output itself is Optional.
      // In this case prepended `Optional.some(nil)` will become just `nil` after `combineLatest`.
      .map { (value: $0, ()) }
      .prepend((value: nil, ()))

    return map { (value: $0, token: UUID()) }
      .combineLatest(other)
      .removeDuplicates(by: { (old, new) in
        let lhs = old.0, rhs = new.0
        return lhs.token == rhs.token
      })
      .map { ($0.value, $1.value) }
      .compactMap { (left, right) in
        right.map { (left, $0) }
      }
      .eraseToAnyPublisher()
  }
}
Josshad
  • 894
  • 7
  • 14
  • No, because `drop(untilOutputFrom` cancels its subscription to the second publisher as soon as it gets a value from that publisher. Thus the original goal cannot be achieved. The goal is to keep listening on both publishers and emit the latest value from the first any time the second emits, perpetually. – matt Feb 24 '21 at 07:10
  • Yep already catch that. I change to not so pretty solution without drop first. – Josshad Feb 24 '21 at 16:38
-2

Kind-of a non-answer, but you could do this instead:

buttonTapped.sink { [unowned self] in
    print(textFieldContent)
}

This code is fairly obvious, no need to know what withLatestFrom means, albeit has the problem of having to capture self.

I wonder if this is the reason Apple engineers didn't add withLatestFrom to the core Combine framework.

Lord Zsolt
  • 6,492
  • 9
  • 46
  • 76