DRIVE II: COMBINE

Scott Orlyck

"We ain't driving tractors here."

In our last episode, we implemented some practical iOS UI programming techniques using RxSwift's Driver trait and UIKit. This time we are going to explore some of the same patterns using Apple's reactive programming framework Combine. Implemented with discipline, Rx based architectures can help you avoid smelly anti-patterns like half-cooked spagehettiNot enough seperation of concerns. or over-cooked lasgnaToo much seperation of concerns., but as with all things it comes with trade-offs.

One of the biggest problems with Rx architectures is the steep learning curve of not only the reactive language extensions themselves but also learning to reason about the system as a declarative stream of events and data as opposed to imperative statefulness. Lucky for us then that Combine adds some much needed developer ergonomics to reactive extensions that makes things a lot easier when operating on publishers across API boundaries.

THE RETURN OF THE DRIVER

Find the source code for a project with working examples here.

If you recall, RxSwift's Driver Trait is a guarantee that the publisher is shared with the last element replayed upon subscription, the subscription is always received on the main thread, and the publisher can never error out. It is instructive to think about Drivers as UI or system events "driving the application".

One of the main driversI am so sorry. of the RxSwift Driver trait was to help make dealing with type inference across API boundaries less problematic. Combine's convenient eraseToAnyPublisher API makes this aspect of the driver trait redundant however it is instructive to walk through the implementation with Combine and the result leaves us with some easily re-usable Rx patterns.

One of the nice things about Combine compared to RxSwift is that the API is much simpler. The primary drawback of this simplicity is that we will need to import CombineExt to provide some operators we need for this architecture pattern to be successful. Nonetheless our code is going to look a lot cleaner thanks to Combine's integration with Foundation. Apple does not provide default publishers for UIKit control events so we will need to add CombineCocoa to our project as well.

Below are two extensions to Publisher to fulfill our requirements for a Driver. The first demonstrates how to use an Empty publisher to convert a Publisher error type to Never.

Lines 3-4: Some typealias conveniences.
Lines 8-9: Catch and return an Empty publisher to convert the error type to Never.
Line 11: Share and replay the last element.
Line 12: Receive on the main thread.

import CombineExt
                
typealias Driver<T> = AnyPublisher<T, Never>
typealias Bag = Set<AnyCancellable>

extension Publisher {
    func driver() -> Driver<Output> {
        `catch` { error -> AnyPublisher<Output, Never> in
            Empty(completeImmediately: true).eraseToAnyPublisher()
        }
        .share(replay: 1)
        .receive(on: RunLoop.main)
        .eraseToAnyPublisher()
    }
}

If it makes more sense to provide a default value to the driver function wrap the default value parameter in a Just publisher instead of returning an Empty publisher.

func driver(onErrorJustReturn: Output) -> Driver<Output> {
    `catch` { error -> Just<Output> in
        Just(onErrorJustReturn)
    }
    .share(replay: 1)
    .receive(on: RunLoop.main, options: nil)
    .eraseToAnyPublisher()
}

One more extension for our UITextfield publisher so we don't have to worry about unwrapping optionals anywhere along our publisher chain.

extension UITextField {
    func textDriver() -> Driver<String> {
        textPublisher.replaceNil(with: "").driver()
    }
}

MVVM + VC

We can't accurately call this pattern MVVM becuase it's more like Model-View-ViewModel-ViewController. The ViewController is responsible for connecting the ViewModel to the Views as well as managing the navigation stack which is exactly what UIViewControllers are supposed to do!

Take a look at the ViewModel implementation below as now is a good time to review some rulesThe ViewModel cannot import UIKit and will never subscribe to any publishers. to help enforce seperation of concerns and prevent side effects.


import Combine
import CombineExt

class ViewModel {
    
    let validatedUsername: Driver<Validation>
    let validatedPassword: Driver<Validation>
    let enabled: Driver<Bool>
    let loggedIn: Driver<Response>

Initialize the ViewModel with the IBOutlet drivers.


init(
    username: Driver<String>,
    password: Driver<String>,
    login: AnyPublisher<Void, Never>
) {
    validatedUsername = username.flatMapLatest {
        Validator.shared.username(username: $0)
    }.driver(onErrorJustReturn: .failed("Invalid username."))
    
    validatedPassword = password.flatMapLatest {
        Validator.shared.password(password: $0)
    }.driver(onErrorJustReturn: .failed("Invalid password"))


    let combined = Publishers.CombineLatest(
        validatedUsername,
        validatedPassword
    )

    enabled = combined
        .flatMapLatest { username, password -> Just<Bool> in
            if case (.success, .success) = (username, password) {
                return Just(true)
            }
            return Just(false)
        }.driver(onErrorJustReturn: false)

    let combinedCredentials = Publishers
        .CombineLatest(
            username,
            password
        )

    loggedIn = login.withLatestFrom(combinedCredentials)
        .flatMapLatest {
            Network.shared.login(username: $0, password: $1)
        }.driver()

The ViewModel isn't too much different than last time except now it is significantly simpler. Define the outputs, connect the inputs and subscribe in the ViewController. Bish bash bosh, next thing you know, Robert's your father's brother.

One thing we wont be implementing here is the drive function itself which serves as a type erased replacement for subscribe in RxSwift. Combine's built-in type erasure has already made our API boundaries tidy and easy to work with.

import CombineCocoa

class ViewController: UIViewController {
    
    @IBOutlet weak var username: UITextField!
    @IBOutlet weak var password: UITextField!
    @IBOutlet weak var login: UIButton!

    var bag = Bag()

    lazy var viewModel: ViewModel = {
        ViewModel(
            username: username.textDriver(),
            password: password.textDriver(),
            login: login.tapPublisher)
    }()

}

Back in our ViewController we can start to connect the ViewModel outputs to the Views.

Since the ViewModel outputs are all Drivers we can rest assured that our subscriptions will be received on the main thread.

viewModel.validatedUsername
    .sink { [weak self] validation in
        if case .failed(let message) = validation {
            self?.usernameError.text = message
            self?.usernameError.isHidden = false
        } else {
            self?.usernameError.isHidden = true
        }
    }.store(in: &bag)

    viewModel.validatedPassword
        .sink { [weak self] validation in
            if case .failed(let message) = validation {
                self?.passwordError.text = message
                self?.passwordError.isHidden = false
            } else {
                self?.passwordError.isHidden = true
            }
    }.store(in: &bag)

Now we can connect the login button enabled state and the request result outlets.

As always, don't forget to use weak self, put the subscriptions in the bag, and call your bind function in viewDidLoad.

viewModel.enabled.sink { [weak self] enabled in
    self?.login.isEnabled = enabled
}.store(in: &bag)

viewModel.loggedIn.sink { [weak self] response in
    if case .success = response {
        self?.performSegue(withIdentifier: "LOGGEDIN", sender: nil)
    }
}.store(in: &bag)

IN THE BAG

Next time we'll explore async/await ;)

Acknowledgements

Styles by Tufte-CSS.

Syntax by PrismJS