How to Use UIKit With MVVM and Combine
Integrate Apple’s Combine into your view models
It’s been a while since Apple’s own reactive framework Combine came onto the scene. Being a RxSwift user for quite some time, I was more interested in Combine than Swift U, so I decided to learn it first.
I started migrating one of my UIKit + Swift + MVVM projects to Combine and MVVM to learn. This project creates an app named Nearby that displays some places around you (restaurants, ATMs, Cafes, etc). In this article, I’ll be sharing my experience of doing the migration, pain points, as well as sharing relevant resources with you.
For the scope of our discussion, let’s take the example of a few pages of Nearby. You can download the Nearby codebase and start referring to the blog.
Configuring the ViewModel
The view shows the name, location, image, open status, and distance of a place from your location. These details will be supplied from the ViewModel
for this view. Let us jump to it:
What’s going on here?
Your ViewModel
receives a NearbyPlace
object which holds all the relevant information for this particular place. The ViewModel
then configures the outputs. Outputs are the data that is to be displayed on the view. So far everything is going great.
You may have noticed that all output properties are annotated with the @Published
keyword. This property wrapper in Combine contains a stored value and its projected value provides users with a Combine publisher, receiving updated values for the property whenever it’s changed. In Swift world, you have to call an update callback or delegate implemented in the view, but in Combine you get it for free!
Configuring the View
Once the view has been loaded, we set up the bindings of our ViewModel
configured output properties to our UI components.
Why do we call this binding? Because here you’re not only assigning your UI components to their respective values, you’re also subscribing to any future changes in that property:
For clarity, we’ve segregated our binding as the ones which can be assigned directly using the assign(to:on:)
API of combine, which assigns a publisher’s output to a property of an object. If you rewind to our last section’s discussion, we’ve annotated our properties in ViewModel
with@Published
. In the binding we’ve used the projected value of a property, using $propertyname
to assign the underlying publisher to the assignable properties of our UI component. The result of every assignment is an AnyCancellable
type.
For example, for assigning $title
to the text
property of titleLabel
, we write viewModel.$title.assign(to: \.text!, on: titleLabel)
.
All AnyCancellable
results are stored in a subscriptions
set, ensuring that your subscriptions are still in memory to receive any upcoming events.
Custom Assigning
For custom handling of attributes, we can use different operators provided by Combine over our Publishers
, such as sink(receiveValue:)
and handleEvents
, to receive the values and work directly on them. In the code snippet above, we used compactMap
to map the stream of CLLocation
values from the $location
publisher to a tuple of MKCoordinateRegion
and MKPointAnnotation
followed by sink
to render the details on the map.
Passing UI Events to the ViewModel
There are times when we need to pass certain UI events to the ViewModel
for further processes — perhaps an API call, a database query, or something else.
The above screen shows the Nearby home page. Certain UI events occur on this view — user taps to refresh the feed, taps on category, taps on any place, etc. These events trigger certain actions in the ViewModel
, like triggering the API or on the UI itself, in navigation for instance. For our discussion,let’s take a communication where the user taps to refresh data on screen:
A PassthroughSubject
can be used to send events to subscribers of your subject. Think of this as your water supply pipe from which you take your daily water supply.
In our case the loadDataSubject
is used by the view to send events to the ViewModel
to load app data from the server. Whenever the user presses on the refresh button or the view loads for the first time we ask the ViewModel
to load the data. If you closely look at the attachViewEventListener(loadData: AnyPublisher<Void, Never>)
implemented in the ViewModel
, we do not actually pass the loadDataSubject
to the ViewModel
to receive the event. Rather we type erase the subject to an AnyPublisher
and send it. An AnyPublisher
can only be used to receive events and not send events. By using this type erasure we are avoiding any chance of abuse of the loadDataSubject
from the ViewModel
.
A similar approach goes for communication between ViewModel
and View
. For reloading our list on a successful API fetch, we usereloadPlaceList: AnyPublisher<Result<Void, NearbyAPIError>, Never>
Pain of UIKit and Combine Compatibility
Missing bindings
Though we have assign(to:on:)
which uses the key path to bind a publisher to any property, it lags some major binding functionality as its Rx counterpart bind(to:)
has. Especially for UIControl
, there’s no straightforward way to send control events to a AnyPublisher
property. In our case, we’ve used PublishSubject
and type erasures to send a button tap event. The community was pretty quick to develop wrappers for missing binding functionality for UIKit
components.
To implement a UIControl binding functionality such as assign, we have to write our own custom publisher which is a lot of overhead for anyone.
Don’t be disheartened. We can still use combine to drive many of our business logic asynchronously and exploit the power of Combine and reactive programming.
Property Wrappers and Protocols
Property wrappers can not be declared in protocol definitions. You may have noticed, we used our ViewModel
s directly in the view, using their concrete types. This reduces the scope of the reusability of our views. If we were to abstract out ViewModel
s and expose our outputs and inputs by protocols, we could not use @Published
directly in protocol definitions. The reason and the workaround are explained in this blog. Feel free to check that out.