Build your own Combine operators: .lasting(atLeast:on:)

Swift’s Combine framework already provides us with lots of useful operators to manipulate streams of values. But it should not prevent you from creating your own custom operators whenever you need it.

Use Case

The .lasting(atLeast:) operator was created as I working on a fitness app. In this app, the fitness coaches can send a client an invitation link which, when clicked, takes them to the app and links them to their coach. During the linking process a linking request is sent to the backend and a custom loading screen is presented to the client telling them they are currently being linked to their coach. So far so good. When testing this at home on a high-speed internet I could notice that the loading screen was only presented for fractions of a second because the linking request succeeded very fast. To solve this problem I decide to show the loading screen for at least a certain amount of time before dismissing it. You should now see where I’m going with this.

Let’s start

Let’s now fake our linking request using a publisher which spits out an output, void in this case, after half a second.

enum RequestError: Error {
    case error
}

let linkingRequest: AnyPublisher<Void, RequestError> = Just(())
    .setFailureType(to: RequestError.self)
    .delay(for: 0.5, scheduler: RunLoop.main)
    .eraseToAnyPublisher()

If we subscribe to this request and calculate the time it takes to receive the completion event. We see that it takes around 0.5 seconds

let start = CFAbsoluteTimeGetCurrent()

let subscription = linkingRequest
    .sink(
        receiveCompletion: { _ in
            let diff = CFAbsoluteTimeGetCurrent() - start
            print("Completion after \(String(format:"%.1f", diff)) seconds")
            // Completion after 0.5 seconds
        },
        receiveValue: { _ in }
    )

The code above already mentions half of the solution to make our linking request last at least a certain amount of time and that’s the .delay(for:scheduler:) operator. But why is it only half of the solution? Couldn’t we simply delay the linking request by a certain amount of time that makes sense, let’s say 3 seconds? While that would certainly prevent the loading screen from flickering, it could also potentially have the user wait for 3 seconds longer than they have to. It would be better if there was a way to delay the request so that its total execution time is rounded up to 3 seconds in case its execution is below 3 seconds but stays the same in case its execution is over 3 seconds.

Zip to the rescue

Apple’s documentation on Combine defines the zip operator as follows:

Combines elements from another publisher and deliver pairs of elements as tuples.

The publisher returned by the zip method waits for both publisher to emit a value before itself emitting a value. That’s makes it the perfect second half for our solution.

Let’s first create a delaying publisher which will delay the emission of the final value. To make it work it needs to have the same Failure type as our fake linking request

let delayingPublisher: AnyPublisher<Void, RequestError> = Just(())
    .setFailureType(to: RequestError.self)
    .delay(for: 3.0, scheduler: RunLoop.main)
    .eraseToAnyPublisher()

We now zip both publishers and subsribe

let delayedLinkingRequest = linkingRequest
    .zip(delayingPublisher)

let subscription = delayedLinkingRequest
    .sink(
        receiveCompletion: { _ in
            // Completion after 3.0 seconds
        },
        receiveValue: { _ in }
    )

If you play with the delay of the linking request you should see that the delayed linking request takes at least 3 seconds and never takes longer that the delay of the linking request.

Before calling it a day let’s move all this beautiful code in an extension to make it reusable.

extension Publisher {
  func lasting(atLeast stride: RunLoop.SchedulerTimeType.Stride, on runLoop: RunLoop)
    -> AnyPublisher<Self.Output, Self.Failure> {
        Just(())
            .setFailureType(to: Self.Failure.self)
            .delay(for: stride, scheduler: runLoop)
            .zip(self)
            .map(\.1)
            .eraseToAnyPublisher()
  }
}

let subscription = linkingRequest
    .lasting(atLeast: 1.0, on: .main)
    .sink(
        receiveCompletion: { _ in
            // Completion after 3.0 seconds
        },
        receiveValue: { _ in }
    )

Thanks for reading!

Leave a Reply

Your email address will not be published.