Reputation: 1313
I want to perform an API call whenever a user types 3 or more letters into a search field.
I finally got it to work, but unfortunately when I turned off and on the server it turned out that the Publisher
terminates upon the first error and when user types text into the search field again no API call is made.
I watched the WWDC 2019 videos on Combine
and read a few blog posts, but it seems that Combine API
changes quite often, every source does everything differently, and when I tinker around it the compiler often throws useless errors like Fix: Replace type X with type X
(see screenshot)
PS: I know I can use filter
to filter out queries shorter than 3 letters, but I somehow couldn't get the publishers and types to work.. I feel like I am missing something fundamental on Combine
...
Here is the code:
DictionaryService.swift
class DictionaryService {
func searchMatchesPublisher(_ query: String,
inLangSymbol: String,
outLangSymbol: String,
offset: Int = 0,
limit: Int = 20) -> AnyPublisher<[TranslationMatch], Error> {
...
}
DictionarySearchViewModel.swift
class DictionarySearchViewModel: ObservableObject {
@Published var inLang = "de"
@Published var outLang = "en"
@Published var translationMatches = [TranslationMatch]()
@Published var text: String = ""
private var cancellable: AnyCancellable? = nil
init() {
cancellable = $text
.debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
.removeDuplicates()
.map { [self] queryText -> AnyPublisher<[TranslationMatch], Error> in
if queryText.count < 3 {
return Future<[TranslationMatch], Error> { promise in
promise(.success([TranslationMatch]()))
}
.eraseToAnyPublisher()
} else {
return DictionaryService.sharedInstance()
.searchMatchesPublisher(queryText, inLangSymbol: self.inLang, outLangSymbol: self.outLang)
}
}
.switchToLatest()
.eraseToAnyPublisher()
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.assign(to: \.translationMatches, on: self)
}
}
Upvotes: 0
Views: 1177
Reputation: 1313
Updated and working code that doesn't terminate the upstream publisher based on matt's answer:
init() {
cancellable = $text
.debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
.filter { $0.count >= 3 }
.removeDuplicates()
.map { [self] queryText -> AnyPublisher<[TranslationMatch], Never> in
DictionaryService.sharedInstance()
.searchMatchesPublisher(queryText, inLangSymbol: self.inLang, outLangSymbol: self.outLang)
.replaceError(with: [TranslationMatch]())
.eraseToAnyPublisher()
}
.switchToLatest()
.eraseToAnyPublisher()
.receive(on: DispatchQueue.main)
.assign(to: \.translationMatches, on: self)
}
Upvotes: 0
Reputation: 534895
This is expected behavior. Once an error has been promulgated down a pipeline, the pipeline is completed. It can be completed as a failure, or it can be completed as a single final value if you use replaceError
, but either way, it's done.
I will illustrate using flatMap
instead of map
plus switchToLatest
, but it's exactly the same principle.
The solution here is to catch or replace the error inside the flatMap
closure, keeping it from percolating down out of the flatMap
. That way, what is completed is the secondary pipeline inside the flatMap
, not the overall outer pipeline.
I will demonstrate with a much-reduced schematic of your situation. I have a text field that I'm typing into, and my view controller is its delegate:
import UIKit
import Combine
enum Oops : Error { case oops }
class ViewController: UIViewController, UITextFieldDelegate {
@IBOutlet weak var tf: UITextField!
@Published var currentText = ""
var pipeline : AnyCancellable!
override func viewDidLoad() {
super.viewDidLoad()
self.pipeline = self.$currentText
.debounce(for: 0.2, scheduler: DispatchQueue.main)
.filter { $0.count > 3 }
.flatMap { s -> AnyPublisher<String,Never> in
Future<String,Error> { promise in
if Bool.random() {
promise(.success(s))
} else {
promise(.failure(Oops.oops))
}
}
.replaceError(with: "yoho")
.eraseToAnyPublisher()
}
.sink(receiveCompletion: { print($0) }, receiveValue: { print($0) })
}
func textFieldDidChangeSelection(_ textField: UITextField) {
self.currentText = textField.text ?? ""
}
}
As you can see, I've configured the Future in the .flatMap
to fail randomly. But because the failure is replaced inside the .flatMap
, that failure does not cause the overall pipeline to stop working. So as you type and backspace and so on, you will sometimes see the text field text printed in the console and sometimes the "yoho"
that I'm using to indicate an error, but the pipeline keeps on working regardless.
If you wanted to use .map
and .switchToLatest
instead, it would be exactly the same code. Where I have flatMap
in the above code, we would instead have this:
.map { s -> AnyPublisher<String, Never> in
Future<String,Error> { promise in
if Bool.random() {
promise(.success(s))
} else {
promise(.failure(Oops.oops))
}
}
.replaceError(with: "yoho")
.eraseToAnyPublisher()
}
.switchToLatest()
Upvotes: 1