Andre
Andre

Reputation: 7668

iOS Swift Combine: Emit Publisher with single value

I'm using Combine and it happens to me many times that I have the need to emit Publishers with single values.

For example when I use flat map and I have to return a Publisher with a single value as an error or a single object I use this code, and it works very well:

return AnyPublisher<Data, StoreError>.init(
           Result<Data, StoreError>.Publisher(.cantDownloadProfileImage)
        )

This creates an AnyPublisher of type <Data, StoreError> and emits an error, in this case: .cantDownloadProfileImage

Here a full example how may usages of this chunk of code.

func downloadUserProfilePhoto(user: User) -> AnyPublisher<UIImage?, StoreError> {
        guard let urlString = user.imageURL,
            let url = URL(string: urlString)
            else {
                return AnyPublisher<UIImage?, StoreError>
                    .init(Result<UIImage?, StoreError>
                        .Publisher(nil))
        }
        return NetworkService.getData(url: url)
            .catch({ (_) -> AnyPublisher<Data, StoreError> in
                return AnyPublisher<Data, StoreError>
                    .init(Result<Data, StoreError>
                        .Publisher(.cantDownloadProfileImage))
            })
            .flatMap { data -> AnyPublisher<UIImage?, StoreError> in
                guard let image = UIImage(data: data) else {
                    return AnyPublisher<UIImage?, StoreError>
                        .init(Result<UIImage?, StoreError>.Publisher(.cantDownloadProfileImage))
                }
                return AnyPublisher<UIImage?, StoreError>
                    .init(Result<UIImage?, StoreError>.Publisher(image))
        }
        .eraseToAnyPublisher()
    }

Is there an easier and shorter way to create an AnyPublisher with a single value inside?

I think I should use the Just() object in somehow, but I can't understand how, because the documentation at this stage is very unclear.

Upvotes: 1

Views: 18176

Answers (2)

Nish
Nish

Reputation: 51

import Foundation
import Combine

enum AnyError<O>: Error {
    case forcedError(O)
}

extension Publisher where Failure == Never {
    public var limitedToSingleResponse: AnyPublisher<Output, Never> {
        self.tryMap {
            throw AnyError.forcedError($0)
        }.catch { error -> AnyPublisher<Output, Never> in
            guard let anyError = error as? AnyError<Output> else {
                preconditionFailure("only these errors are expected")
            }
            switch anyError {
            case let .forcedError(publishedValue):
                return Just(publishedValue).eraseToAnyPublisher()
            }
        }.eraseToAnyPublisher()
    }
}

let unendingPublisher = PassthroughSubject<Int, Never>()

let singleResultPublisher = unendingPublisher.limitedToSingleResponse

let subscription = singleResultPublisher.sink(receiveCompletion: { _ in
    print("subscription ended")
}, receiveValue: {
    print($0)
})

unendingPublisher.send(5)




In the snippet above I am converting a passthroughsubject publisher which can send a stream of values into something that stops after sending the first value. The essence of the snippet in based on the WWDC session about introduction to combine https://developer.apple.com/videos/play/wwdc2019/721/ here.

We are esentially force throwing an error in tryMap and then catching it with a resolving publisher using Just which as the question states will finish after the first value is subscribed to.

Ideally the demand is better indicated by the subscriber.

Another slightly more quirky alternative is to use the first operator on a publisher

let subscription_with_first = unendingPublisher.first().sink(receiveCompletion: { _ in
    print("subscription with first ended")
}, receiveValue: {
    print($0)
})

Upvotes: 0

rob mayoff
rob mayoff

Reputation: 386068

The main thing we can do to tighten up your code is to use .eraseToAnyPublisher() instead of AnyPublisher.init everywhere. This is the only real nitpick I have with your code. Using AnyPublisher.init is not idiomatic, and is confusing because it adds an extra layer of nested parentheses.

Aside from that, we can do a few more things. Note that what you wrote (aside from not using .eraseToAnyPublisher() appropriately) is fine, especially for an early version. The following suggestions are things I would do after I have gotten a more verbose version past the compiler.

We can use Optional's flatMap method to transform user.imageURL into a URL. We can also let Swift infer the Result type parameters, because we're using Result in a return statement so Swift knows the expected types. Hence:

func downloadUserProfilePhoto(user: User) -> AnyPublisher<UIImage?, StoreError> {
    guard let url = user.imageURL.flatMap({ URL(string: $0) }) else {
        return Result.Publisher(nil).eraseToAnyPublisher()
    }

We can use mapError instead of catch. The catch operator is general: you can return any Publisher from it as long as the Success type matches. But in your case, you're just discarding the incoming failure and returning a constant failure, so mapError is simpler:

    return NetworkService.getData(url: url)
        .mapError { _ in .cantDownloadProfileImage }

We can use the dot shortcut here because this is part of the return statement. Because it's part of the return statement, Swift deduces that the mapError transform must return a StoreError. So it knows where to look for the meaning of .cantDownloadProfileImage.

The flatMap operator requires the transform to return a fixed Publisher type, but it doesn't have to return AnyPublisher. Because you are using Result<UIImage?, StoreError>.Publisher in all paths out of flatMap, you don't need to wrap them in AnyPublisher. In fact, we don't need to specify the return type of the transform at all if we change the transform to use Optional's map method instead of a guard statement:

        .flatMap({ data in
            UIImage(data: data)
                .map { Result.Publisher($0) }
                ?? Result.Publisher(.cantDownloadProfileImage)
        })
        .eraseToAnyPublisher()

Again, this is part of the return statement. That means Swift can deduce the Output and Failure types of the Result.Publisher for us.

Also note that I put parentheses around the transform closure because doing so makes Xcode indent the close brace properly, to line up with .flatMap. If you don't wrap the closure in parens, Xcode lines up the close brace with the return keyword instead. Ugh.

Here it is all together:

func downloadUserProfilePhoto(user: User) -> AnyPublisher<UIImage?, StoreError> {
    guard let url = user.imageURL.flatMap({ URL(string: $0) }) else {
        return Result.Publisher(nil).eraseToAnyPublisher()
    }
    return NetworkService.getData(url: url)
        .mapError { _ in .cantDownloadProfileImage }
        .flatMap({ data in
            UIImage(data: data)
                .map { Result.Publisher($0) }
                ?? Result.Publisher(.cantDownloadProfileImage)
        })
        .eraseToAnyPublisher()
}

Upvotes: 7

Related Questions