aerial shot of green milling tractor

A better way to structure Combine with UIKit in MVVM

Combine and SwiftUI work beautifully together. But how can Combine be used to work with reactive code in our UIKit projects?

With a background in reactive programming in UIKit with RxSwift and RxCocoa, I was intrigued to see if Combine could be used in a similar manner to that which I’m already accustomed to in the world of RxSwift. I’ve seen some confusion over how Combine should be used with UIKit, so let’s explore some possibilities.

Reactive = Input -> Output

In imperative programming, you would typically see a view model with several methods such as fetchData or createPost that would be called by the view controller in a ‘do this’ and ‘now do that’ manner. Often these methods would be called in response to some kind of user input i.e. a button tap, or view life cycle event i.e. the view will appear.

In reactive programming, these asynchronous ‘do this’ and ‘do that’ events can be modelled as streams of values of time. You can imagine these streams as a series of pipes, each containing ‘things’ such a button taps or the text being typed into a search bar. These pipes or streams can be plugged into one another to describe what will happen at some point in time.

So we can model all of the inputs for a particular screen in a struct. These inputs or actions generated by the user will likely mean ‘something’ should happen i.e. the stream of text coming from a search bar as the user types, should make an API call with the search query. This will result in some kind of output i.e. a list of search results and perhaps an error message. This output can also be modelled as an object.

struct SearchInput {
    let searchQuery: AnyPublisher<String, Never>
}

class SearchOutput {
    @Published var photos: [Photo] = []
    @Published var errorMessage: String = ""
}

The view model can then transform the inputs into outputs.

final class SearchViewModel {
    
    func bind(_ input: SearchInput) -> SearchOutput {
        
        let output = SearchOutput()
        
        // take the input.searchQuery
        // create an API request
        // parse the response into [Photo]
            .assign(to: &output.$photos)
        
        // capture any errors
            .assign(to: &output.$errorMessage)
        
        return output
    }
}

Notice how the assign(to:)operator is used. This will republish values to the underlying publishers of @Published properties (Look back at SearchOutput above). This is much neater than having a subscription in the view model which sets the value of the outputs via the sink operator. You should avoid subscriptions in your view models. Also note that you must use a class to contain the outputs as the properties are marked @Published.

final class SearchViewController: UIViewController {
    
    private let viewModel = SearchViewModel()
    private var subscriptions = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let output = viewModel.bind(SearchInput(...))
        
        output.$photos
            .sink { [ display the photos ] }
            .store(in: &subscriptions)
        
        output.$errorMessage
            .sink { [ display the error message ] }
            .store(in: &subscriptions)
    }
}

From a single function in the view controller’s viewDidLoad() to a single function in the view model (bind(_:), we have bound the view and view model in a fully reactive manner 🥳. Having an input struct and an output class is useful when the numbers of inputs and outputs grow, as it can help you to neatly organise your code. This also helps you to think through ‘what are the inputs for this view?’ and ‘what are the outputs for this view model?’ before you write any other code.

But something is missing 🤔

Whilst Combine can be used with UIKit, it doesn’t provide publishers for controls like text fields, search bars, buttons etc. So we cannot get out of the box, a stream of text entered into a search bar from which to create our input. This is something RxSwift developers are used to in the guise of RxCocoa. Combine was clearly engineered for SwiftUI and this is where Apple are pushing everything, so I’d be surprised if we see further UIKit integration in future.

We can however create publishers for these controls ourselves, and there’s a couple of options. The easy way is to use subjects in our view controllers, and imperatively send events to these subjects. Checkout this post for a nice solution using this technique. The harder way is to create new publishers ourselves. You can see a great example here. For the purposes of a quick demo, I’m going to take the middle road and use CombineCocoa to provide these publishers for me, whilst also keeping everything reactive (with no imperative code in the view controller).

A quick demo 🚀

Below is a quick demo of the theory we’ve discussed with some real working code. The view produces a single input; a search query stream generated by the search bar (thanks to CombineCocoa). The view model then transforms this into two outputs; an array of photo models retrieved from the Unsplash search API to be displayed in a collection view, and a boolean value to indicate whether search is taking place which will be used to drive an activity indicator above the search bar.

Notice that I’m not using an input struct as we only have a single input, and that the outputs are simply properties of the view model. This is a slightly different way of achieving the same input to output transformation.

final class SearchViewModel {

    @Published var photos: [Photo] = []
    @Published var searching: Bool = false

    func bind(searchQuery: AnyPublisher<String, Never>) {

        let search = searchQuery
            .debounce(for: .seconds(0.5), scheduler: DispatchQueue.global())
            .map { URLRequest.searchPhotos(query: $0) }
            .share()

        let photos = search
            .map { API.publisher(for: $0) }
            .switchToLatest()
            .decode(type: SearchPhotos.self, decoder: API.jsonDecoder)
            .replaceError(with: .emptyResults)
            .share()

        photos
            .map(\.results)
            .receive(on: DispatchQueue.main)
            .assign(to: &$photos)

        search
            .map { _ in true }
            .merge(with: photos
                    .map { _ in false }
            .replaceError(with: false)
            .receive(on: DispatchQueue.main)
            .assign(to: &$searching)
    }
}

The view controller then binds the two outputs as described above. I’m using CombineDataSources here to bind the photos to the datasource for my collection view.

final class SearchViewController: UIViewController {
    
    @IBOutlet weak var activityView: UIActivityIndicatorView!
    @IBOutlet weak var searchBar: UISearchBar!
    @IBOutlet weak var collectionView: UICollectionView!
    
    private let viewModel = SearchViewModel()
    private var subscriptions = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
    
        viewModel.bind(searchQuery: searchBar.textDidChangePublisher)
        
        viewModel.$photos
            .bind(subscriber: collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PhotoCell.self, cellConfig: { cell, _, photo in
                cell.bind(photo)
              }))
              .store(in: &subscriptions)
        
        viewModel.$searching
            .sink { [weak activityView] searching in
                searching ? activityView?.startAnimating() : activityView?.stopAnimating()
            }
            .store(in: &subscriptions)
        
        searchBar.searchButtonClickedPublisher
            .sink { [weak searchBar] in
                searchBar?.resignFirstResponder()
            }
            .store(in: &subscriptions)
    }
}

If you’d like to grab a copy of the demo head to GitHub. (You’ll need to create an Unsplash developer account and add your client ID to Requests.swift to actually run the project and call the Unsplash API).

Whilst there are some sizeable gaps in comparison to what RxSwift and RxCocoa provides, it is possible to work reactively in UIKit with Combine. I hope this has helped demonstrate some interesting techniques. Happy coding! 🚜

Stay in the Loop

Subscribe to tapdev and never miss a post.

Total
0
Shares
Previous Post
stock of timber logs with scratches

Unit Testing basics: what exactly should you test?

Next Post
an eagle flying in the sky

Cancel in-flight network requests using Combine

Related Posts