简体   繁体   中英

Swift Combine operator with same functionality like `withLatestFrom` in the RxSwift Framework

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 ?

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.

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()
  }
}

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.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM