Creating a custom Combine Publisher to extend UIKit

A Custom Combine Publisher can add missing functionalities to UIKit elements you use every day. A lot of boilerplate code can be removed and implementations can be simplified.

A simple example is responding to UIControl events. The standard library comes with a few great extensions to, for example, decode JSON easily and assign values to key paths. Unfortunately, responding to UIControl events is not in there. Although you might expect it in a later update, let’s use it as an example to create our own custom Combine Publisher.

Just getting started with Combine? You might want to first take a look at Getting started with the Combine framework in Swift or my Combine Playground.

Creating a custom Combine Subscription

Before we create our custom Combine Publisher, we first need to create our own Combine subscription type. For this, we need to conform to the Subscription protocol which inherits from the Cancellable protocol. The latter gives us the cancel() method which is required to handle the cancellation of a subscription.

Let’s take a look at the code and break it down after:

/// A custom subscription to capture UIControl target events.
final class UIControlSubscription<SubscriberType: Subscriber, Control: UIControl>: Subscription where SubscriberType.Input == Control {
    private var subscriber: SubscriberType?
    private let control: Control

    init(subscriber: SubscriberType, control: Control, event: UIControl.Event) {
        self.subscriber = subscriber
        self.control = control
        control.addTarget(self, action: #selector(eventHandler), for: event)
    }

    func request(_ demand: Subscribers.Demand) {
        // We do nothing here as we only want to send events when they occur.
        // See, for more info: https://developer.apple.com/documentation/combine/subscribers/demand
    }

    func cancel() {
        subscriber = nil
    }

    @objc private func eventHandler() {
        _ = subscriber?.receive(control)
    }
}

Defining the expected Publisher input type

We start by defining our expected Publisher input type which we restrict to be of the type UIControl. This allows us to keep a generic UIControl type through our Publisher stream without casting it down to UIControl types only. If the input is a UISwitch, the downstream will receive a UISwitch type as well.

Handling the demand of subscribers

Looking at the documentation we can see that there are two types of demands:

  • unlimited: A request for an unlimited number of items.
  • max: A request for a maximum number of items.

An example of an unlimited subscriber is the sink method which requests an unlimited number of values upon subscription. In this case, you can send forward values without completing until the subscription is canceled.

When max is set, you should limit the number of values send through. The Publisher may send fewer values than the requested number but never more than the set max.

In our UIControl events example, we will not respond to any demands. We only want to send out values through the Publisher stream at the moment they occur.

Handling cancelations

Handling cancelations is as simple as calling releasing our reference to our subscriber. This will release any retained references and will make sure that our memory is cleaned up correctly.

Sending out a value when a UIControl event occurs

This is the most important part of our example: sending values through our Custom Combine Publisher subscription. This is also the reason why we kept a strong reference to the subscriber. We can use it to let the subscriber receive our UIControl when the set control event occurs. As we have set our expected input type to be a UIControl, we need to send the same value through the subscription as well.

A subscriber contains the following receive methods:

    • func receive(subscription: Subscription)
      Tells the subscriber that it has successfully subscribed to the publisher and may request items.
    • func receive(Self.Input) -> Subscribers.Demand
      Needs to be used to let the subscriber know that the publisher has produced an element.
    • func receive(completion: Subscribers.Completion<Self.Failure>)
      Will be called when the publisher has completed publishing, either normally or with an error.

As our subscription is unlimited until it’s been canceled, we only use the receive(Self.Input) method.

Creating a Custom Combine Publisher

A Custom Combine Publisher needs to implement the Publisher protocol and define the expected output and failure types. These types define the type which is sent through the Publisher stream.

Let’s take a look at the code:

/// A custom `Publisher` to work with our custom `UIControlSubscription`.
struct UIControlPublisher<Control: UIControl>: Publisher {

    typealias Output = Control
    typealias Failure = Never

    let control: Control
    let controlEvents: UIControl.Event

    init(control: Control, events: UIControl.Event) {
        self.control = control
        self.controlEvents = events
    }
    
    func receive<S>(subscriber: S) where S : Subscriber, S.Failure == UIControlPublisher.Failure, S.Input == UIControlPublisher.Output {
        let subscription = UIControlSubscription(subscriber: subscriber, control: control, event: controlEvents)
        subscriber.receive(subscription: subscription)
    }
}

Just like with our custom Combine subscription we now set the output type to UIControl. Our initializer requires to pass in the control reference and the events to listen for.

The only required method to implement is the receive<S>(subscriber: S) in which we need to send the custom subscription to the subscriber. For this, we can simply use the earlier mentioned func receive(subscription: Subscription) on the subscriber reference.

This is enough to complete our custom Combine Publisher!

Using the custom Combine Publisher in practice

We can use the custom Combine Publisher in practice in a lot of cases:

  • Responding to UITextField text change events
  • Subscribing to UISlider value changed events
  • Handling UIButton touch events

The last one will be our example to show how it can be used.

A UIControl extension to easily access our custom Publisher

First, we create a custom UIControl extension to make it easy to access our custom Combine Publisher. For this, we use an empty protocol called CombineCompatible and we make UIControl conform to it.

/// Extending the `UIControl` types to be able to produce a `UIControl.Event` publisher.
protocol CombineCompatible { }
extension UIControl: CombineCompatible { }
extension CombineCompatible where Self: UIControl {
    func publisher(for events: UIControl.Event) -> UIControlPublisher {
        return UIControlPublisher(control: self, events: events)
    }
}

Responding to touch events

By using this extension it’s quite easy to respond to touch events on a UIButton.

let button = UIButton()
button.publisher(for: .touchUpInside).sink { button in
    print("Button is pressed!")
}
button.sendActions(for: .touchUpInside)

This is a very simple example in which we print out “Button is pressed!” once the button is tapped. However, this is a great starting point to, for example, validate form input and submit if the form input is valid.

Conclusion

This is just a simple example of what you can do with a custom Combine Publisher. Most of the times you’ll be able to do everything you need with the standard available APIs. However, if you want to dive into it, even more, take a look at the following resources:

Thanks!

Collect by WeTransfer Collect by WeTransfer is the best way to organize your ideas. Save content from across your apps and bring it together for your friends, your team, or just for yourself.