Combine Framework with UIKit.
At WWDC 2019 Apple introduced a new framework which implements the Reactive Programming concepts called Combine. It provides a declarative API for handling asynchronous events. In case you don't know what declarative programming means, I saw a nice comment in the Internet which says; “ Declarative programming is the picture, where imperative programming is instructions for painting that picture.” I still recommend you to google it.
Combine is implemented on top of three protocols: Publisher, Subscriber and Subscription. Publisher transmits values that can change over time, Subscriber receives those values from the publisher and Subscription represents the connection of a subscriber to a publisher. Combine is one of the components that makes SwiftUI possible, however if you don't want to go all in, you can leave out SwiftUI for now and use Combine with UIKit. In this article, I am going to explain how to manipulate views and handle user interactions using Combine along with UIKit. I also sprinkled some handy extensions for you. I believe this article can be a starting point to learn how to handle more complex things with the Combine framework.
Let’s imagine a single view with a button in the middle and the button background color changes with the button action. We are going to accomplish that by using the Combine Framework instead of closures or delegates. The button transmits user interactions, therefore a Subscriber is needed to receive them, and a Publisher to transmit a new color. As a first step, let’s model the view with a following structure that will handle both button color changes and the button action observation:
struct MainViewModel {
let buttonAction: AnySubscriber<Void, Never>
let buttonColor: AnyPublisher<UIColor?, Never>
}
Since we don't want to send any errors, we can leverage a typesystem to ensure that by using Never as error type. And for the output of the button action we can simply use Void. UIButton background color accepts an optional UIColor, so I decided to use an optional output for buttonColor publisher.
After initialising the view , we need to subscribe to the button action and publish the button color. The code below shows how to do this:
class MainView: UIView {
private let button = UIButton()
private var cancellables = Set<AnyCancellable>()
private var buttonPublisher: AnyPublisher<Void, Never>
init(viewModel: MainViewModel) {
//1
buttonPublisher = button.publisher(for: .touchUpInside)
.map { _ in Void() }
.eraseToAnyPublisher()
// 2
buttonPublisher.subscribe(viewModel.buttonAction)
super.init(frame: .zero)
buildSubViews()
// 3
button.assign(
viewModel.buttonColor,
to: \.backgroundColor
)
.store(in: &cancellables)
}
...
}
- One of the main problems here is to detect the button action. In our case it is a touch up inside event which we would like to receive as an output of buttonPublisher. Unfortunately, UIKit does not provide an extension to handle UIControl events. Here is a custom publisher I've written to detect UIControl events:
final class UIControlPublisher<Control: UIControl>: Publisher {
typealias Output = UIControl
typealias Failure = Never
private let control: UIControl
private let event: UIControl.Event
private let subject = PassthroughSubject<Output, Failure>()
deinit {
control.removeTarget(self, action: nil, for: event)
}
init(control: UIControl, event: UIControl.Event) {
self.control = control
self.event = event
control.addTarget(
self,
action: #selector(handler(sender:)),
for: event
)
}
func receive<S>(subscriber: S) where S : Subscriber,
S.Failure == UIControlPublisher.Failure,
S.Input == UIControlPublisher.Output {
subject.subscribe(subscriber)
}
@objc private func handler(sender: UIControl) {
subject.send(sender)
}
}
The PassthroughSubject behaves like a man in the middle. It receives the subscription and sends values when the handler method is fired. The handler method is not different from the selector method which we used to use in the classic implementation. Finally, I've extended UIControl with the publisher method which returns the UIControlPublisher;
extension UIControl {
func publisher(for event: UIControl.Event) ->
UIControlPublisher<UIControl> {
return .init(control: self, event: event)
}
}
The buttonPublisher sends UIControl events so we subscribe to it with the buttonAction subscriber to receive these events.
We would like to send (publish) a new color to change the button background color by using buttonColor publisher. Publisher has an assign method that assigns output of a publisher to a property on an object:
publisher(
for: KeyPath<UIView, Value>,
options: NSKeyValueObservingOptions
)
In our case, the output of buttonAction publisher is assigned to the backgroundColor property of the button. I have written an extension to make the code more readable.
extension UIView {
func assign<Value>(
_ publisher: AnyPublisher<Value, Never>,
to key: ReferenceWritableKeyPath<UIView, Value>
) -> AnyCancellable {
return publisher.assign(to: key, on: self)
}
}
Swift KeyPath feature makes the magic here. The method assigns the output of a publisher to the given keyPath. if you don't want to use an extension. You could use such a method instead:
UIView().publisher(
for: KeyPath<UIView, Value>,
options: NSKeyValueObservingOptions
).assign(
to: <ReferenceWritableKeyPath<Root, Bool>,
on: Root
)
Finally, here is the viewModel method which does all the important business. You could locate it in a viewController or in an Interactor depending on the pattern you use.
func viewModel() -> MainViewModel{
// 1
let buttonSubject = PassthroughSubject<Void, Never>()
let buttonSubscriber = AnySubscriber(buttonSubject)
// 2
let colorPublisher = buttonSubject
.map { self.randomColor() }
.map(Optional.init)
.prepend(.gray)
.eraseToAnyPublisher()
return MainViewModel(
buttonAction: buttonSubscriber,
buttonColor: colorPublisher
)
}
Let's go through the snippet together and see what it does;
- I created a PassthroughSubject and attached it to an AnySubscriber. The subject emits all events received by a subscriber. And If the buttonSubject completes its events or cancels the subscription, buttonSubscriber does too. Emitting events with the subject gives an opportunity to use these events for different purposes. In our case, we change the button background color. However you could fetch some JSON files or send the analytics.
Here is the Apple’s definition for the PassthroughSubject:
‘’A subject that broadcasts elements to downstream subscribers. PassthroughSubject doesn’t have an initial value or a buffer of the most recently-published element. A PassthroughSubject drops values if there are no subscribers, or its current demand is zero.’’
We don't pass the PassthroughSubject to the view model directly. PassthroughSubject has the send() method which allows us to send values or complete events which means we could send events from anywhere we call the viewModel function (for example viewController). We don’t want this to happen.
let colorPublisher = buttonSubject
.map { self.randomColor() }
.map(Optional.init)
.prepend(.gray)
.eraseToAnyPublisher()
- The buttonSubject has Void as its output. We would like to send a random color when the button action triggers. When buttonSubject receives a new event, we map it to an optional random color. We also define an initial value by the prepend method. So far we have;
let colorPublisher: Publishers.Concatenate<Publishers.Sequence<[Optional<UIColor>], Never>, Publishers.Map<PassthroughSubject<Void, Never>, Optional<UIColor>>>
It looks beautiful doesn't it? Luckily, the Publisher protocol has a function called erasToAnyPublisher which wrapps this ugly publisher type with a type eraser and converts it to an AnyPublisher. Finally, we obtainAnyPublisher<UIColor?, Never>
. What all these conversions do is publish a random color each time when the button triggers.
There are many different ways to implement this simple example. However. it can give you some ideas to use Combine in more complex applications. If you would like to see the complete version, please visit my gitlab account. I hope this article is useful. If you enjoyed my article or have some additional questions feel free to comment on it.
Where to go from here: