3

I was looking for good solutions for loading images asynchronously from a remote server image URL. There were many solutions online. It's a shame Apple doesn't provide one natively for something that is so common. Anyways, I found Sundell's blog really interesting and took the good bits from it to create my own ImageLoader, as shown below:

import Combine

class ImageLoader {

    private let urlSession: URLSession
    private let cache: NSCache<NSURL, UIImage>

    init(urlSession: URLSession = .shared,
         cache: NSCache<NSURL, UIImage> = .init()) {
        self.urlSession = urlSession
        self.cache = cache
    }

    func publisher(for url: URL) -> AnyPublisher<UIImage, Error> {
        if let image = cache.object(forKey: url as NSURL) {
            return Just(image)
                .setFailureType(to: Error.self)
                .receive(on: DispatchQueue.main)
                .eraseToAnyPublisher()
        } else {
            return urlSession
                .dataTaskPublisher(for: url)
                .map(\.data)
                .tryMap { data in
                    guard let image = UIImage(data: data) else {
                        throw URLError(.badServerResponse, userInfo: [
                            NSURLErrorFailingURLErrorKey: url
                        ])
                    }
                    return image
                }
                .receive(on: DispatchQueue.main)
                .handleEvents(receiveOutput: { [cache] image in
                    cache.setObject(image, forKey: url as NSURL)
                })
                .eraseToAnyPublisher()
        }
    }
}

As you can see the publisher provides an instance of AnyPublisher<UIImage, Error>. I'm not entirely sure on how to use this ImageLoader in my MyImageView shown below:

struct MyImageView: View {

    var url: URL
    var imageLoader = ImageLoader()

    @State private var image = #imageLiteral(resourceName: "placeholder")

    var body: some View {
        Image(uiImage: image)
            .onAppear {
                let cancellable = imageLoader.publisher(for: url).sink(receiveCompletion: { failure in
                    print(failure) // doesn't print
                }, receiveValue: { image in
                    self.image = image // not getting executed
                })
                cancellable.cancel() // tried with and without this line.
            }
    }
}

How do I extract the UIImage from the ImageLoader publisher that returns an instance of AnyPublisher<UIImage, Error>?

Frankenstein
  • 15,732
  • 4
  • 22
  • 47
  • Your `let cancellable` is deallocated as soon as you finish executing `onAppear`. You need to retain the reference long enough for the request to execute. Best to move it to some *class* (like `ObservableObject`). Note that you still need to declare your `cancellable` on the class level, so it won't be immediately deallocated. – pawello2222 Nov 16 '20 at 09:26
  • @pawello2222 would it be wrong if I made the `ImageLoader` conform to `ObservableObject`? – Frankenstein Nov 16 '20 at 10:11
  • Nope, conforming `ImageLoader` to `ObservableObject` makes sense if you don't want too many classes. – pawello2222 Nov 16 '20 at 10:46
  • @pawello2222 I've added my approach below let me know it needs any changes or can be improved further. – Frankenstein Nov 16 '20 at 10:50

3 Answers3

6

You have to use an ObservableObject for subscribing to the publisher provided by ImageLoader.

class ImageProvider: ObservableObject {
    @Published var image = UIImage(named: "icHamburger")!
    private var cancellable: AnyCancellable?
    private let imageLoader = ImageLoader()

    func loadImage(url: URL) {
        self.cancellable = imageLoader.publisher(for: url)
            .sink(receiveCompletion: { failure in
            print(failure)
        }, receiveValue: { image in
            self.image = image
        })
    }
}

struct MyImageView: View {
    var url: URL
    @StateObject var viewModel = ImageProvider()
    var body: some View {
        Image(uiImage: viewModel.image)
            .onAppear {
                viewModel.loadImage(url: url)
            }
    }
}
sElanthiraiyan
  • 6,000
  • 1
  • 31
  • 38
  • Could you explain a bit why you're using the `bag` property to store `AnyCancellable`? What would happen if it's not used? Are there any alternatives? Also, after applying this solution I'm getting the keyword "finished" printed in the console(from failure) instead of the image. FYI: URL has valid image. – Frankenstein Nov 16 '20 at 09:31
  • Image loading now works perfectly. Had to change `StateObject` to `ObservedObject` due to `iOS 13.0` support requirement. – Frankenstein Nov 16 '20 at 09:40
  • bag is used to store the subscription and retain it. We could also use a simple AnyCancellable variable. We just have to retain the subscription, otherwise it will get deallocated before serving its purpose. – sElanthiraiyan Nov 16 '20 at 09:43
  • 1
    bag is useful when we have to store multiple subscriptions. Used a simple AnyCancellable variable and edited my answer. I hope this makes things clear for you. – sElanthiraiyan Nov 16 '20 at 09:51
1

Since I didn't want too much segregation for a simple ImageLoader, I made it conform to ObservableObject. So, I'm just modifying the answer provided by @sElanthiraiyan. Also, with a bit more research I found that the publisher needs to be stored when required and deallocated when it's no longer used. Here's the modified code

ImageLoader:

class ImageLoader: ObservableObject {

    @Published var image: UIImage
    private var bag = Set<AnyCancellable>()
    //...

    init(placeholder: UIImage = UIImage(),
         urlSession: URLSession = .shared,
         cache: NSCache<NSURL, UIImage> = .init()) {
        self.image = placeholder
        //...
    }

    //...

    func load(from url: URL) {
        publisher(for: url)
            .sink(receiveCompletion: { _ in })
            { image in
                self.image = image
            }
            .store(in: &bag)
    }
}

MyImageView:

struct MyImageView: View {

    var url: URL
    @StateObject var imageLoader = ImageLoader(placeholder: UIImage(named: "placeholder")!) // or @ObservedObject if iOS 13 support is required

    var body: some View {
        Image(uiImage: imageLoader.image)
            .onAppear {
                imageLoader.load(from: url)
            }
    }
}
Frankenstein
  • 15,732
  • 4
  • 22
  • 47
  • I believe you don't need `deinit` - from [Apple documentation](https://developer.apple.com/documentation/combine/anycancellable): *"An AnyCancellable instance automatically calls cancel() when deinitialized."* – pawello2222 Nov 16 '20 at 11:09
  • Thanks for the input. I've removed the custom `deinit` implementation. – Frankenstein Nov 16 '20 at 11:57
0

iOS 15+

struct ContentView: View {
    var body: some View {
        if #available(iOS 15.0, *) {
            AsyncImage(url: URL(string: "https://unsplash.com/photos/_ce9iqvtF90/download?force=true"), transaction: .init(animation: .spring())) { phase in
                         switch phase {
                         case .empty:
                             Color.green
                             .opacity(0.2)
                             .transition(.opacity.combined(with: .scale))
                         case .success(let image):
                           image
                             .resizable()
                             .aspectRatio(contentMode: .fill)
                             .transition(.opacity.combined(with: .scale))
                         case .failure(let error):
                             Color.red
                         @unknown default:
                             Color.yellow
                         }
                       }
                       .frame(width: 400, height: 266)
                       .mask(RoundedRectangle(cornerRadius: 16))
        } else {
            // Fallback on earlier versions
        }

    }
}
YodagamaHeshan
  • 4,996
  • 2
  • 26
  • 36