jjrscott

Combine and SwiftUI onReceive

I recently wanted to build a custom publisher using Apple’s Swift Combine framework. It was hard work until I saw an answer by rob mayoff showing how to wrap an existing publisher. Obvious, but in my case, only in hindsight.

As a side effect of my work I was able to work out what SwiftUI does when you use View.onReceive(_:perform:). Below are the state diagrams for the view appearing, disappearing, and a button press:

Appear

> Subject receive(subscriber:)
    > Subscriber receive(subscription:)
        - Subscription request(_:) demand: unlimited
        - Subscription request(_:) demand: unlimited
    < Subscriber receive(subscription:)
< Subject receive(subscriber:)
- View onAppear

Disappear

> Subscription cancel()
    - Subscriber deinit
< Subscription cancel()
- Subscription deinit
- View onDisappear

Send (via button press)

> Button perform(:)
    > Subject send(_:) value: «value»
        - Subscriber receive(_:) input: «value»
    < Subject send(_:)
< Button perform(:)
- View onReceive(:) output: «value»

The code

Here’s the outline of the code I used to completely wrap a Publisher, a Subject, and for added points, CurrentValueSubject. It doesn’t strictly do anything but you can use it as a base to add any extras you might need. It also does a reasonable job of showing you how publishers are structured, if you look at it for long enough.

import Combine

final class Tracker<Output, Failure, Target>: Publisher where Target: Publisher, Output == Target.Output, Failure == Target.Failure {
    private let target: Target
    init(_ target: Target) {
        self.target = target
    }

    func receive<Downstream: Subscriber>(subscriber: Downstream) where Failure == Downstream.Failure, Output == Downstream.Input {
        target.subscribe(TrackedSubscriber(subscriber))
    }

    deinit { }
}

extension Tracker: Subject where Target: Subject {
    func send(_ value: Output) {
        target.send(value)
    }

    func send(completion: Subscribers.Completion<Failure>) {
        target.send(completion: completion)
    }

    func send(subscription: Subscription) {
        target.send(subscription: subscription)
    }
}

extension Tracker where Target == CurrentValueSubject<Output, Failure> {
    var value: Output {
        get { target.value }
        set { send(newValue) }
    }
}

final class TrackedSubscriber<Input, Failure, Target>: Subscriber where Target: Subscriber, Input == Target.Input, Failure == Target.Failure {
    private let target: Target
    init(_ target: Target) {
        self.target = target
    }

    func receive(subscription: any Subscription) {
        target.receive(subscription: TrackedSubscription(subscription))
    }

    func receive(_ input: Input) -> Subscribers.Demand {
        return target.receive(input)
    }

    func receive(completion: Subscribers.Completion<Failure>) {
        target.receive(completion: completion)
    }

    deinit { }
}

final class TrackedSubscription: Subscription {
    private let target: Subscription
    init(_ target: Subscription) {
        self.target = target
    }

    func request(_ demand: Subscribers.Demand) {
        target.request(demand)
    }

    func cancel() {
        target.cancel()
    }

    deinit { }
}