koleS
koleS

Reputation: 1313

SwiftUI + Combine - Publisher terminates upon first error

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)

enter image description here

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

Answers (2)

koleS
koleS

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

matt
matt

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

Related Questions