Swift Combine Cheat Sheet

2023/07/04

Swift Combine Cheat Sheet

Publisher

Subject

Subscriber

Operator

Scheduler

Publisher

Declares that a type can transmit a sequence of values over time.

let publisher = ["cat", "dog", "monkey"].publisher

Apple has created a bunch of publishers that are defined as extensions of objects.

data task publisher:

URLSession.shared.dataTaskPublisher(for: url)

default notification center publisher:

NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotificaiton)

Future

A publisher that eventually produces a single value and then finishes or fails.

func generateAsyncRandomNumberFromFuture() -> Future <Int, Never> {
    return Future() { promise in
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            let number = Int.random(in: 1...10)
            promise(Result.success(number))
        }
    }
}

cancellable = generateAsyncRandomNumberFromFuture()
    .sink { number in print("Got random number \(number).") }
func createFuture() -> Future<Int, Never> {
    Future { promise in
        promise(.success(Int.random(in: (1...100))))
    }
}

func createDeferredFuture() -> Deferred<Future<Int, Never>> {
    return Deferred {
        Future { promise in
            promise(.success(Int.random(in: (1...100))))
        }
    }
}

Subscriber

A protocol that declares a type that can receive input from a publisher.

Built-in general-purpose subscribers

sink:

Attaches a subscriber with closure-based behavior.

["apple", "orange", "banana"].publisher.sink(receiveCompletion: { completion in
    print("completed: \(completion)")
}, receiveValue: { value in
    print("value received: \(value)")
})
[1, 2, 3].publisher.sink {
    print("received value \($0)")
}

assign:

Assigns each element from a publisher to a property on an object.

var player = Player()
["something nice"]
    .publisher
    .assign(to: \.introduction, on: player)

Publisher Lifecycle

[10, 14, 23]
    .publisher
    .map { "The area of the square with size \($0) is \($0 * $0)" }
    .sink { string in
        label.text = string
    }
["2", "birds", "with", "1", "stone"]
    .map { Int($0) }
    .replaceNil(with: 0)
    .sink { print($0) }
// 2
// 0
// 0
// 1
// 0

Subject

A subject is a publisher that you can use to ”inject” values into a stream, by calling its send(_:) method. This can be useful for adapting existing imperative code to the Combine model.

class Hero {
    var mana = CurrentValueSubject<Int, Never>(10)

    let manaCostPerPowerLevel = 1

    func castMagic(powerLevel: Int) {
        let manaNeeded = powerLevel * manaCostPerPowerLevel
        mana.value -= manaNeeded
    }
}

var cancellables = Set<AnyCancellable>()
let hero = Hero()
hero
    .mana
    .sink {
        print("Current mana: \($0)")
    }
    .store(in: &cancellables)

@Published property wrapper

Wrapping properties with the @Published property wrapper will turn them into publishers.

class Hero {
    @Published var mana = 10 // 1

    let manaCostPerPowerLevel = 1

    func castMagic(powerLevel: Int) {
        let manaNeeded = powerLevel * manaCostPerPowerLevel
        mana -= manaNeeded // 2
    }
}

var cancellables = Set<AnyCancellable>()
let hero = Hero()
hero
    .$mana // 3
    .sink {
        print("Current mana: \($0)")
    }
    .store(in: &cancellables)
  1. replaces var mana = CurrentValueSubject<Int, Never>(10) with @Published var mana = 10
  2. the value of the property can be accessed and modified directly
  3. to subscribe to a @Published property, we need to use $ prefix on the property name

This is a special convention for property wrapper that allows you to access the wrapper itself, aka “projected value”, rather than the value that is wrapped by the property. In the case where the wrapper’s projected value is a publisher, which can be subscribed to using $mana.

class Counter {
    @Published var publishedValue = 1
    var subjectValue = CurrentValueSubject<Int, Never>(1)
}

let c = Counter()
c.$publishedValue.sink { print("published", $0 == c.publishedValue) }
c.subjectValue.sink { print("subject", $0 == c.subjectValue.value) }
c.publishedValue = 2
c.subjectValue.value = 2

Output:

published true
subject true
published false
subject true

assign(to:on:)

Assign Output of a Publisher with assign(to:on:)

class Hero {
    @Published var mana = 10
    let manaCostPerPowerLevel = 1
}

struct HeroViewModel {
    var hero: Hero

    lazy var statusSubject: AnyPublisher<String?, Never> = {
        hero.$mana.map {
            "Hero now has \($0) mana left"
        }.eraseToAnyPublisher()
    }

    mutating func castMagic(powerLevel: Int) {
        let manaNeeded = powerLevel * manaCostPerPowerLevel
        hero.mana -= manaNeeded
    }
}

class HeroView {
    let statusLabel = UILabel()
    let castButton = UIButton()
    var viewModel: HeroViewModel
    var cancellables = Set<AnyCancellable>()

    init(viewModel: HeroViewModel) {
        self.viewModel = viewModel
    }

    func setupLabel() {
        viewModel.statusSubject
            .assign(to: \.text, on: statusLabel)
            .store(in: &cancellables)
    }

    func didTapCastButton() {
        viewModel.castMagic(powerLevel: 2)
    }
}

Operators

debounce

Built-in operator that limits publisher’s output by ignoring values that are rapidly followed by another value.

Use the debounce(for:scheduler:options:) operator to control the number of values and time between delivery of values from the upstream publisher. This operator is useful to process bursty or high-volume event streams where you need to reduce the number of values delivered to the downstream to a rate you specify.

removeDuplicates

Publishes only elements that don’t match the previous element.

Use removeDuplicates() to remove repeating elements from an upstream publisher. This operator has a two-element memory: the operator uses the current and previously published elements as the basis for its comparison.

filter

Republishes all elements that match a provided closure.

Combine’s filter(_:) operator performs an operation similar to that of filter in the Swift Standard Library: it uses a closure to test each element to determine whether to republish the element to the downstream subscriber.

throttle

Publishes either the most-recent or first element published by the upstream publisher in the specified time interval.

Use throttle(for:scheduler:latest:) to selectively republish elements from an upstream publisher during an interval you specify. Other elements received from the upstream in the throttling interval aren’t republished.

Transform Publisher Output to New Publisher

Sometimes you need to transform the output of a publisher into a different publisher:

var cancellables = Set<AnyCancellable>()

[1, 2, 3]
    .publisher
    .print()
    .flatMap { value in
        Array(repeating: value, count: 2).publisher
    }
    .sink { print("Got value: \($0)") }
    .store(in: &cancellables)

Output:

receive subscription: ([1, 2, 3])
request unlimited
receive value: (1)
Got value: 1
Got value: 1
receive value: (2)
Got value: 2
Got value: 2
receive value: (3)
Got value: 3
Got value: 3
receive finished
var cancellables = Set<AnyCancellable>()

[1, 2, 3]
    .publisher
    .print()
    .flatMap(maxPublishers: .max(1)) { value in
        Array(repeating: value, count: 2).publisher
    }
    .sink { print("Got value: \($0)") }
    .store(in: &cancellables)

Output:

receive subscription: ([1, 2, 3])
request max: (1)
receive value: (1)
Got value: 1
Got value: 1
request max: (1)
receive value: (2)
Got value: 2
Got value: 2
request max: (1)
receive value: (3)
Got value: 3
Got value: 3
request max: (1)
receive finished
let baseURL = URL(string: "https://www.test.com")!
var cancellables = Set<AnyCancellable>()
["/", "/content", "/pages", "/articles"]
    .publisher
    .setFailureType(to: URLError.self)
    .flatMap({ path -> URLSession.DataTaskPublisher in
        let url = baseURL.appendingPathComponent(path)
        return URLSession.shared.dataTaskPublisher(for: url)
    })
    .sink(receiveCompletion: { completion in
        print("Completed with: \(completion)") },
          receiveValue: { result in print(result)})
    .store(in: &cancellables)

Apply Operators that might Throw Error

Operators with a try prefix can throw error:

enum MyError: Error {
    case underEighteen
}

var cancellables = Set<AnyCancellable>()

[18, 17, 22, 35]
    .publisher
    .tryMap { age in
        guard age >= 18 else {
            throw MyError.underEighteen
        }
        return "Another player of age \(age) entered the game."
    }
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })

Output:

Another player of age 18 entered the game.
failure(__lldb_expr_13.MyError.underEighteen)

Define Custom Operators

extension Publisher where Output == String, Failure == Never {
    func toURLSessionDataTask(baseURL: URL) -> AnyPublisher<URLSession.DataTaskPublisher.Output, URLError> {
        if #available(iOS 14, *) {
            return flatMap {
                let url = baseURL.appendingPathComponent($0)
                return URLSession.shared.dataTaskPublisher(for: url)
            }
            .eraseToAnyPublisher()
        } else {
            return setFailureType(to: URLError.self)
                .flatMap {
                    let url = baseURL.appendingPathComponent($0)
                    return URLSession.shared.dataTaskPublisher(for: url)
                }
                .eraseToAnyPublisher()
        }
    }
}

["path1", "path2"]
    .publisher
    .toURLSessionDataTask(baseURL: URL(string: "https://myweb.com")!)
    .sink { print($0) }

Scheduler

Scheduler is the synchronization mechanism of the Combine framework, which defines the context for where and when the work is performed.

Combine does not work directly with threads. Instead, it allows Publishers to operate on specific Schedulers.

receive(on:) and subscribe(on:)

By default, Combine applies a default scheduler to the work, and the default scheduler will emit values downstream on the thread they were generated on.

extension Publishers {
  struct IsMainThread: Publisher {
    typealias Output = String
    typealias Failure = Never

    func receive<S>(subscriber: S) where S : Subscriber,
      Never == S.Failure, String == S.Input {

      debugPrint("IsMainThread: \(Thread.isMainThread)")
      subscriber.receive(subscription: Subscriptions.empty)

      DispatchQueue.main.async {
        _ = subscriber.receive("test")
      }
    }
  }
}

Without subscribe(on:):

Publishers.IsMainThread()
    .sink { _ in
      print("Sink: \(Thread.isMainThread)")
    }
    .store(in: &cancellables)

// output:
// "IsMainThread: true"
// Sink: true

With subscribe(on: DispatchQueue.global()):

Publishers.IsMainThread()
    .subscribe(on: DispatchQueue.global())
    .sink { _ in
      print("Sink: \(Thread.isMainThread)")
    }
    .store(in: &cancellables)
// output:
// "IsMainThread: false"
// Sink: true

Switching receive(on:):

Publishers.IsMainThread() // 1
  .receive(on: DispatchQueue.global()) // 2
  .map { value -> String in // 3
    debugPrint("Map: \(Thread.isMainThread)")
    return value
  }
  .receive(on: DispatchQueue.main) // 4
  .sink { _ in // 5
    print("Sink: \(Thread.isMainThread)")
  }
  .store(in: &cancellables)

// output:
// "IsMainThread: true"
// "Map: false"
// Sink: true
  1. the subscription is created on the current queue - the main queue
  2. switch the executuion to a new global queue
  3. map() executes
  4. switch to main queue
  5. sink executes

Mix and Match

In a multi-threaded environment it’s likely that you need a chain of operations including fetching data, processing data and updates UI, receive(on:) and subscribe(on:) can be mixed and matched:

[Resource Intensive Publisher]
  .subscribe(on: DispatchQueue.global())
  .receive(on: DispatchQueue.global())
  [Resource Intensive Processing of Output]
  .receive(on: DispatchQueue.main)
  [Update App UI on the Main Queue]

Use Cases

CollectionView via Combine

Use combine to drive collection view, when the underlying data changes, update the collection view so it displays newest data.

struct Player: Hashable, Decodable {
    let firstName: String
    let lastName: String
    let photoURL: URL
}

class DataProvider {

    let dataSubject = CurrentValueSubject<[Player], Never>([])

    func fetch() {
        let mockPlayers = [
            Player(firstName: "Lei",
                   lastName: "Li",
                   photoURL: URL(string: "http://player.photo/lei")!),
            Player(firstName: "Meimei",
                   lastName: "Han",
                   photoURL: URL(string: "http://player.photo/meimei")!)
        ]
        dataSubject.value = mockPlayers
    }

    func fetchImage(for player: Player) -> AnyPublisher<UIImage?, URLError> {
        URLSession.shared.dataTaskPublisher(for: player.photoURL)
            .map { UIImage(data: $0.data) }
            .eraseToAnyPublisher()
    }
}

final class PlayerCell: UICollectionViewCell {
    var cancellable: Cancellable?
    let imageView = UIImageView()
    // setup code omitted
    func configure(with player: Player) {
        // configure the cell
    }
}

final class PlayersViewController: UIViewController {

    private let dataProvider = DataProvider()
    private var cancellables = Set<AnyCancellable>()

    private lazy var dataSource: UICollectionViewDiffableDataSource<Int, Player> = {
        .init(collectionView: collectionView) { collectionView, indexPath, player in
            let cell = // dequeue PlayerCell
            cell.configure(with: player)
            cell.cancellable = self.dataProvider.fetchImage(for: player)
                .receive(on: DispatchQueue.main)
                .sink { cell.imageView.image = $0 }
                // .assign(to: \.image, on: cell.imageView)
            return cell
        }
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        dataProvider.fetch()
        dataProvider.dataSubject
            .sink { self.applySnapshots($0) }
            .store(in: &cancellables)
    }

    private func applySnapshots(_ players: [Player]) {
        var snapshot = NSDiffableDataSourceSnapshot<Int, Player>()
        snapshot.appendSections([0])
        snapshot.appendItems(players)
        dataSource.apply(snapshot)
    }
}

Assigning Publisher Output to @Published Property

Building an object that exposes a @Published property that represents some kind of state that can be updated due to external factors.

class DataProvider {
    @Published var fetchedPlayers = [Player]()

    private var currentPlayerID = 0

    func fetchNextPlayer() {
        let url = URL(string: "https://mygameserver.com/player/\(currentPlayerID)")!
        currentPlayerID += 1
        URLSession.shared.dataTaskPublisher(for: url)
            .tryMap { [weak self] value -> [Player] in
                let players = try JSONDecoder().decode([Player].self, from: value)
                return (self?.fetchedPlayers ?? []) + players
            }
            .replaceError(with: fetchedPlayers)
            .assign(to: &$fetchedPlayers)
    }
}

Simple Theming System

class ColorManager {
    enum PreferredUserInterfaceStyle: String {
        case lowEyeStrain, highContrast, system
    }

    lazy private(set) var themeSubject: CurrentValueSubject<PreferredUserInterfaceStyle, Never> = {
        var preferredStyle = PreferredUserInterfaceStyle.system
        return CurrentValueSubject<PreferredUserInterfaceStyle, Never>(currentInterfaceStyle)
    }()

    private var currentInterfaceStyle: PreferredUserInterfaceStyle {
        get {
            guard let styleValue = UserDefaults.standard.string(forKey: "preferredUserInterfaceStyle") else {
                return .system
            }
            return PreferredUserInterfaceStyle(rawValue: styleValue) ?? .system
        }
        set {
            UserDefaults.standard.set(newValue.rawValue, forKey: "preferredUserInterfaceStyle")
        }
    }
}

class SomeViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        themeManager.themeSubject.sink { style in
            updateUI(for: style)
        }
    }

    private func updateUI(for: style) {
        // …
    }
}

Updating UI based on User Input

class ViewController: UIViewController {
    let slider = UISlider()
    let label = UILabel()

    @Published var sliderValue: Float = 40

    var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        $sliderValue
            .map { "Slider is at \($0)" }
            .assign(to: \.text, on: label)
            .store(in: &cancellables)
        $sliderValue
            .assign(to: \.value, on: slider)
            .store(in: &cancellables)
        
        slider.addTarget(self, #selector(updateLabel), for: .valueChanged)
    }

    @objc
    func updateLabel() {
        sliderValue = slider.value
    }
}

Search Query

Perform search as user types:

final class ResultViewController: UIViewController {
    let textField = UITextField()
    let label = UILabel()

    @Published var searchQuery: String?

    var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        textField.addTarget(self, action: #selector(textChagned), for: .editingChanged)
        $searchQuery
            .debounce(for: 0.3, scheduler: DispatchQueue.global()) // 1
            .filter { ($0 && "").count > 2 } // 2
            .removeDuplicates() // 3
            .assign(to: \.text, on: label)
            .store(in: &cancellables)
    }

    @objc
    func textChanged() {
        searchQuery = textField.text
    }
}
  1. ignore values that are rapidly followed by another value; the timer is scheduled on global queue rather than the main queue, in order to not pause the timer when main queue is busy. For example, you might be scrolling through a table view while the timer is running in which case the timer will be paused until the scroll ends
  2. ignore values that are less than or equal to 2 characters
  3. ignore values that are duplicates of the previous values

Combine multiple publishers

Publishers.Zip

A publisher created by applying the zip function to two upstream publishers.

Use Publishers.Zip to combine the latest elements from two publishers and emit a tuple to the downstream. The returned publisher waits until both publishers have emitted an event, then delivers the oldest unconsumed event from each publisher together as a tuple to the subscriber.

If either upstream publisher finishes successfully or fails with an error, so too does the zipped publisher.

In addition to Publishers.Zip, there are Publishers.Zip3 and Publishers.Zip4.

Publishers.Zip(first, second)
    .sink { print("Zipped value: \($0)") }
    .store(in: &cancellables)
first.zip(with: second)
    .sink { print("Zipped value \($0)") }
    .store(in: &cancellables)

Publishers.Merge

A publisher created by applying the merge function to two upstream publishers.

Publishers.Merge emits a new value every time one of the publishers it merges emits a new value. Only a single value is emitted by interleaving all emiited values from the publishers that it merges.

Apple provides various versions of Publishers.Merge up to Publishers.Merge8 and Publishers.MergeMany. All versions of Pubilshers.Merge require that the publishers being merged have the same Output and Failure types.

Publishers.Merge(first, second)
    .sink { print("Merged value: \($0)") }
    .store(in: &cancellables)
first.merge(with: second)
    .sink { print("Merged value: \($0)") }
    .store(in: &cancellables)

Publishers.CombineLatest

Subscribes to an additional publisher and invokes a closure upon receiving output from either publisher.

Use combineLatest<P,T>(_:) to combine the current and one additional publisher and transform them using a closure you specify to publish a new value to the downstream. The combined publisher doesn’t produce elements until each of its upstream publishers publishes at least one element.

All upstream publishers need to finish for this publisher to finish. If an upstream publisher never publishes a value, this publisher never finishes. If any of the combined publishers terminates with a failure, this publisher also fails.

switchToLatest

Republishes elements sent by the most recently received publishers. Every time a publisher receives a new ‘Publisher’ as output, the previous subscriptions from the stream will automatically be cancelled.

Handle Network Call with Token

struct APIError: Decodable, Error {
    let statusCode: Int
}

func refreshToken() -> AnyPublisher<Bool, Never> {
    return Just(false).eraseToAnyPublisher()
}

func fetchURL<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
    URLSession.shared.dataTaskPublisher(for: url)
        .tryMap { result in
            let decoder = JSONDecoder()
            guard let urlResponse = result.response as? HTTPURLResponse,
                (200...299).contains(urlResponse.statusCode) else {
                    let apiError = try decoder.decode(APIError.self, from: result.data)
                    throw apiError
                }
            return try decoder.decode(T.self, from: result.data)
        }
        .tryCatch { error -> AnyPublisher<T, Error> in
            guard let apiError = error as? APIError, apiError.statusCode == 401 else {
                throw error
            }
            return refreshToken()
                .tryMap { success -> AnyPublisher<T, Error> in
                    guard success else { throw error }
                    return fetchURL(url)
                }
                switchToLatest().eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
}

UI based on Multiple Network Requests

Display a screen of sport news, including:

enum SectionType: String, Decodable {
    case highlights, favorites, recommended
}

struct NewsItem: Decodable, Hashable {
    let title: String
    let body: String
}

struct Content {
    let items: [NewsItem]
    let sectionType: SectionType

    static func highlights(items: [NewsItem]) -> Content {
        Content(items: items, sectionType: .highlights)
    }

    static func favorites(items: [NewsItem]) -> Content {
        Content(items: items, sectionType: .favorites)
    }

    static func recommended(items: [NewsItem]) -> Content {
        Content(items: items, sectionType: .recommended)
    }
}

var highlightsPublisher = URLSession.shared.dataTaskPublisher(for: highlightsContentURL)
    .map { $0. data }
    .decode(type: [NewsItem].self, decoder: JSONDecoder())
    .replaceError(with: [NewsItem]())
    .map { Content.highlights(items: $0) }
    .eraseToAnyPublisher()

var recommendedPublisher = URLSession.shared.dataTaskPublisher(for: recommendedContentURL)
    .map { $0.data }
    .decode(type: [NewsItem].self, decoder: JSONDecoder())
    .replaceError(with: [NewsItem]())
    .map { Content.recommended(items: $0) }
    .eraseToAnyPublisher()

class LocalFavorites {
    static func fetchAll() -> AnyPublisher<[NewsItem], Never> {
        // …
    }
}

var localFavoritesPublisher = LocalFavorites.fetchAll()
var remoteFavoritesPublisher = URLSession.shared.dataTaskPublisher(for: favoritesContentURL)
    .map { $0.data }
    .decode(type: [NewsItem].self, decoder: JSONDecoder())
    .replaceError(with: [Event]())
    .eraseToAnyPublisher()

var favouritesPublisher = Publishers.Zip(localFavoritesPublisher, remoteFavoritesPublisher)
    .map {
        let uniqueFavorites = Set($0.0 + $0.1)
        return Content.favorites(items: Array(uniqueFavorites))
    }
    .eraseToAnyPublisher()

var homeContentPublisher = Publishers.Merge3(highlightsPublisher,
                                             favoritesPublisher,
                                             recommendedPublisher)

homeContentPublisher
    .sink { content in
        switch content.sectionType {
        case .highlight: // render
        case .favorites: // render
        case .recommended: // render
        }
    }
    .store(in: &cancellables)

Ask for Push Permissions

extension UNUserNotificationCenter {
    func getNotificationSettings() -> Future<UNNotificationSettings, Never> {
        Future { promise in
            self.getNotificationSettings { settings in
                promise(.success(settings))
            }
        }
    }

    func requestAuthorization(options: UNAuthorizationOptions) -> Future<Bool, Error> {
        Future { promise in
            self.requestAuthorization(options: options) { result, error in
                if let error = error {
                    promise(.failure(error))
                } else {
                    promise(.success(result))
                }
            }
        }
    }
}
var cancellables = Set<AnyCancellable>()

UNUserNotificationCenter.current().getNotificationSettings()
    .flatMap { settings -> AnyPublisher<Bool, Never> in
        switch settings.authorizationStatus {
        case .notDetermined:
            return UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
                .replaceError(with: false)
                .eraseToAnyPublisher()
        case .denied:
            return Just(false).eraseToAnyPublisher()
        default:
            return Just(true).eraseToAnyPublisher()
        }
    }
    .receive(on: DispatchQueue.main)
    .sink { hasPermission in
        if hasPermission {
            // …
        } else {
            // …
        }
    }
    .store(in: &cancellables)

Observe UIControl Event with Publisher

Refer to: https://github.com/CombineCommunity/CombineCocoa for more.

Build a UIControl extension that can be subscribed to deal with specified event:

slider
    .publisher(for: .valueChanged)
    .sink { control in
        guard let slider = control as? UISlider else {
            return
        }
        // handle slider.value
    }

Extension:

extension UIControl {
    class EventSubscription<S: Subscriber>: Subscription where S.Input == UIControl, S.Failure == Never {
        let control: UIControl
        let event: UIControl.Event
        var subscriber: S?
        var currentDemand = Subscribers.Demand.none

        init(control: UIControl, event: UIControl.Event, subscriber: S) {
            self.control = control
            self.event = event
            self.subscriber = subscriber

            control.addTarget(self, action: #selector(onEvent), for: event)
        }

        func request(_ demand: Subscribers.Demand) {
            currentDemand += demand
        }

        func cancel() {
            subscriber = nil
            control.removeTarget(self, action: #selector(onEvent), for: event)
        }

        @objc
        func onEvent() {
            if currentDemand > 0 {
                currentDemand += subscriber?.receive(control) ?? .none
                currentDemand -= 1
            }
        }
    }

    struct EventPublisher: Publisher {
        typealias Output = UIControl
        typealias Failure = Never

        let control: UIControl
        let controlEvent: UIControl.Event

        func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, UIControl == S.Input {
            let subscription = EventSubscription(control: control, event: controlEvent, subscriber: subscriber)
            subscriber.receive(subscription: subscription)
        }
    }

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