Articles, podcasts and news about Swift development, by John Sundell.

Building custom Combine publishers in Swift

Published on 26 Jul 2020
Discover page available: Combine

Apple’s Combine framework provides a general-purpose abstraction for asynchronous programming, by enabling various values and events to be emitted, transformed and observed over time.

Within the world of Combine, an object that emits such asynchronous values and events is called a publisher, and although the framework does ship with quite a large number of built-in publisher implementations, sometimes we might want to build our own, custom ones in order to handle specific situations.

This week, let’s take a look at what sort of situations that might warrant a custom publisher, and a few examples of what building one could entail.

Built-in alternatives

Before we jump into building custom publishers, however, let’s start by taking a look at a few of the built-in alternatives that Combine ships with. Perhaps the currently most common way of using Combine is through the @Published property wrapper, which plays a really important role in SwiftUI’s overall state management system.

However, that property wrapper can also be used outside of SwiftUI as well, and provides a way to automatically generate a publisher that emits a new value whenever a given property was changed. For example, here we’re adding that sort of functionality to an item property contained within a TodoList class:

class TodoList {
    @Published private(set) var items: [TodoItem]
    
    ...

    func addItem(named name: String) {
        items.append(TodoItem(name: name))
    }
}

By simply adding the above annotation to our items property, we’re now able to use Combine to both observe and transform any changes to that property’s value, since any @Published-marked property can easily be turned into a publisher using its projected value — like this:

let list = TodoList(...)

// Observing our property's value directly:
let allItemsSubscription = list.$items.sink { items in
    // Handle new items
    ...
}

// Extracting the first element from each emitted array:
let firstItemSubscription = list.$items
    .compactMap(\.first)
    .sink { item in
        // Handle the first item
        ...
    }

To learn more about the above pattern, check out “Published properties in Swift”.

Let’s also take a quick look at subjects, which sort of act as “mutable publishers”, in that they can both be observed and be sent values to emit. Because of that mutable aspect, it’s typically a good idea to carefully consider what subjects to expose as part of our public API, since doing so enables any outside object to send values to those subjects.

For example, the following CanvasView uses a PassthroughSubject to emit a CGPoint value whenever it was tapped by the user — but it keeps that subject private, since we only want the canvas itself to be able to send values to it. Instead, we’re using the eraseToAnyPublisher method to convert our subject into a read-only publisher, which outside objects can then observe:

class CanvasView: UIView {
    var tapPublisher: AnyPublisher<CGPoint, Never> {
        tapSubject.eraseToAnyPublisher()
    }

    private let tapSubject = PassthroughSubject<CGPoint, Never>()
    
    ...

    @objc private func handle(_ recognizer: UITapGestureRecognizer) {
        let location = recognizer.location(in: self)
        tapSubject.send(location)
    }
}

Like its name implies, a PassthroughSubject simply passes any values that were sent to it along to its observers, without storing those values. A CurrentValueSubject, on the other hand, stores a copy of the latest value that was sent to it, which can then later be retrieved.

Just like the @Published-based publishers from before, our above tapPublisher can be both observed and transformed using the various operators that Combine offers — like this:

let canvas = CanvasView()
...

// Here we're discarding any point that's identical to
// the one before it:
let subscription = canvas.tapPublisher
    .removeDuplicates()
    .sink { point in
        // Handle tap
        ...
    }

So published properties and subjects are both great starting points whenever we’re looking to build a Combine-powered API, and enable us to build a wide range of functionality without having to write any custom publishing code at all. However, there are still situations in which we might need a bit of extra control over how our various events are emitted, which might require building a brand new Publisher type.

Building a publisher from the ground up

One kind of situation that might warrant a custom publisher is whenever that publisher is tied to another object that we don’t have complete control over.

As an example, let’s say that we wanted to build a publisher for observing whenever a given UIControl emits an event. Those types of observations are typically performed using the classic target/action pattern, which is an Objective-C convention, and thus relies on things like selectors and reference types.

While there’s certainly nothing wrong with those conventions, they might feel a little bit dated within modern Swift contexts — so let’s see what a “Combine take” on that concept could look like.

In this case, since we’re looking to add a publisher to any UIControl, we have to build a custom one — since that’ll enable us to properly connect the control that’s being observed to the objects that are subscribing to it. To get started, let’s extend UIControl with an EventPublisher type that conforms to Combine’s Publisher protocol, and then implement the logic required to attach a new subscriber to it:

extension UIControl {
    struct EventPublisher: Publisher {
        // Declaring that our publisher doesn't emit any values,
        // and that it can never fail:
        typealias Output = Void
        typealias Failure = Never

        fileprivate var control: UIControl
        fileprivate var event: Event

        // Combine will call this method on our publisher whenever
        // a new object started observing it. Within this method,
        // we'll need to create a subscription instance and
        // attach it to the new subscriber:
        func receive<S: Subscriber>(
            subscriber: S
        ) where S.Input == Output, S.Failure == Failure {
            // Creating our custom subscription instance:
            let subscription = EventSubscription<S>()
            subscription.target = subscriber
            
            // Attaching our subscription to the subscriber:
            subscriber.receive(subscription: subscription)

            // Connecting our subscription to the control that's
            // being observed:
            control.addTarget(subscription,
                action: #selector(subscription.trigger),
                for: event
            )
        }
    }
}

Next, let’s implement the EventSubscription type that we’re using above. We’ll once again use an extension on UIControl (only this time we’ll keep it private), and we’ll make our new type conform to Combine’s Subscription protocol:

private extension UIControl {
    class EventSubscription<Target: Subscriber>: Subscription
        where Target.Input == Void {
        
        var target: Target?

        // This subscription doesn't respond to demand, since it'll
        // simply emit events according to its underlying UIControl
        // instance, but we still have to implement this method
        // in order to conform to the Subscription protocol:
        func request(_ demand: Subscribers.Demand) {}

        func cancel() {
            // When our subscription was cancelled, we'll release
            // the reference to our target to prevent any
            // additional events from being sent to it:
            target = nil
        }

        @objc func trigger() {
            // Whenever an event was triggered by the underlying
            // UIControl instance, we'll simply pass Void to our
            // target to emit that event:
            target?.receive(())
        }
    }
}

With both our custom publisher and subscription type completed, we’re now almost finished with our new Combine-powered control event API. All that remains is to once again extend UIControl with a method that lets us create a publisher for a given Event — like this:

extension UIControl {
    func publisher(for event: Event) -> EventPublisher {
        EventPublisher(
            control: self,
            event: event
        )
    }
}

With all of the above pieces in place, let’s take our new API for a spin by creating a UIButton, which we can now attach a tap observer to using Combine:

let button = UIButton()
...

let subscription = button.publisher(for: .touchUpInside).sink {
    // Handle tap
    ...
}

While the above is already quite cool from a technical standpoint, the question is what sort of real practical value that it provides over using the built-in target/action API, or a much simpler closure-based extension.

One of the advantages of this approach is that Combine’s various APIs were designed to be incredibly composable — which means that we can use the above to quite easily create increasingly specialized APIs for specific controls. For example, here’s how we could create convenience APIs for both UIButton and UITextField, simply by combining our new publisher with additional operators:

extension UIButton {
    var tapPublisher: EventPublisher {
        publisher(for: .touchUpInside)
    }
}

extension UITextField {
    var textPublisher: AnyPublisher<String, Never> {
        publisher(for: .editingChanged)
            .map { self.text ?? "" }
            .eraseToAnyPublisher()
    }
}

But perhaps the biggest advantage of our new publisher-based control event API is Combine’s ability to, well, combine various data streams in really powerful ways. For example, here we’re using the combineLatest operator to automatically combine the latest values from three separate text fields into one:

class ShippingInfoViewController: UIViewController {
    @Published private(set) var shippingInfo = ShippingInfo()

    private lazy var nameTextField = UITextField()
    private lazy var addressTextField = UITextField()
    private lazy var cityTextField = UITextField()
    private var cancellables = [AnyCancellable]()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(nameTextField)
        view.addSubview(addressTextField)
        view.addSubview(cityTextField)

        // Observe all three of our text fields at once, and
        // combine all of their values into a ShippingInfo
        // instance, which we then assign to a published property:
        nameTextField.textPublisher.combineLatest(
            addressTextField.textPublisher,
            cityTextField.textPublisher
        ).sink { [weak self] name, street, city in
            self?.shippingInfo = ShippingInfo(
                name: name,
                street: street,
                city: city
            )
        }.store(in: &cancellables)
    }
}

Worth noting is that when using combineLatest, no combined value will be emitted before each of the participating publishers has sent at least one value, which is perfectly fine in the above kind of situation.

Now that’s really cool — but perhaps even cooler is that we can continue to make our above Combine pipeline even more compact, for example by utilizing the fact that Swift supports first class functions — which lets us map our tuple of String values directly into our ShippingInfo type’s initializer:

nameTextField.textPublisher.combineLatest(
    addressTextField.textPublisher,
    cityTextField.textPublisher
)
.map(ShippingInfo.init)
.sink { [weak self] in
    self?.shippingInfo = $0
}
.store(in: &cancellables)

But let’s not stop there, because as soon as we’re ready to migrate to the new APIs introduced in Xcode 12 and iOS 14, we can further simplify the above by using a new variant of the assign operator — which both lets us pass a published property directly into it, and doesn’t require us to keep track of any Cancellable tokens:

nameTextField.textPublisher.combineLatest(
    addressTextField.textPublisher,
    cityTextField.textPublisher
)
.map(ShippingInfo.init)
.assign(to: &$shippingInfo)

That’s really the beauty of Combine and reactive programming in general — that by utilizing various operators, and by combining multiple publishers, we can create really sophisticated pipelines that automatically handle any changes to our underlying data.

Keeping up with demand

Finally, let’s return to one aspect of building custom Combine publishers that we previously skipped, and that’s the demand system. While our UIControl.Event publisher didn’t have to use that system, since it was essentially just a wrapper around another type of event, that likely won’t be the case for all custom publishers.

For example, the following Feed publisher continuously emits new values as long as its provider closure doesn’t return nil:

struct Feed<Output>: Publisher {
    typealias Failure = Never

    var provider: () -> Output?

    func receive<S: Subscriber>(
        subscriber: S
    ) where S.Input == Output, S.Failure == Never {
        let subscription = Subscription(feed: self, target: subscriber)
        subscriber.receive(subscription: subscription)
    }
}

To decide when a Subscription instance created by the above publisher should emit its values, we’ll then use the request method that we previously ignored, along with its demand parameter — which contains an Int-based value that indicates how many output values that the current subscriber is interested in receiving — giving us an implementation that looks like this:

private extension Feed {
    class Subscription<Target: Subscriber>: Combine.Subscription
        where Target.Input == Output {

        private let feed: Feed
        private var target: Target?

        init(feed: Feed, target: Target) {
            self.feed = feed
            self.target = target
        }

        func request(_ demand: Subscribers.Demand) {
            var demand = demand

            // We'll continue to emit new values as long as there's
            // demand, or until our provider closure returns nil
            // (at which point we'll send a completion event):
            while let target = target, demand > 0 {
                if let value = feed.provider() {
                    demand -= 1
                    demand += target.receive(value)
                } else {
                    target.receive(completion: .finished)
                    break
                }
            }
        }

        func cancel() {
            target = nil
        }
    }
}

The benefit of the above approach is that we won’t start loading and emitting values until there’s some form of demand for those values — which Combine will automatically manage based on the subscribers that’ll end up connecting to our publisher:

// Simply creating a Feed instance doesn't make it start loading
// and emitting values:
let imageFeed = Feed { imageProvider.provideNextImage() }

// Once we start subscribing to our feed, it'll recieve demand,
// and will start emitting values:
let subscription = imageFeed.sink { image in
    ...
}

So, in general, whenever we’re building a custom publisher that can decide when to emit values on its own, it’s typically a good idea to use the request method and its demand parameter to decide what amount of values to send to a given subscriber, and to not start emitting values until there’s demand for them.

Conclusion

The fact that Combine enables us to build our own custom publishers, subscribers and subscriptions is incredibly powerful — but it’s still arguably something that we should only do if needed. After all, if it turns out that we’re able to use one of the built-in publishers that Combine ships with, then we’ll likely end up with less code to both write and maintain.

Thankfully, migrating from using a built-in publisher to a custom one is usually quite easy, since any custom publishers that we’ll end up building can make use of the exact same features and operators as the built-in ones can — which is, in general, a huge advantage of writing our own asynchronous abstractions on top of Combine.

Got questions, feedback or comments? Feel free to reach out either via Twitter or email.

Thanks for reading! 🚀