SwiftUI Generic Image Loading

Update: 6 August 2019 - Added revamped ImageLoadingView and ImageLoader which resolve problems with images not loading after updating parent view

It’s been over a month, almost two even, since SwiftUI was announced. It’s finally my turn to write a blogpost about it 😅.

When I saw first examples of SwiftUI views, I almost instantly asked myself “how do you load images from web?”. It’s a very common task, probably every iOS developer did it at least hundred times. In UIKit, the most common approach I encountered is a helper method on UIImageView which takes url and uses it to load the image. I’m pretty sure every image fetching & caching library, like Kingfisher or SDWebImage, has its own version of this extension.

In SwiftUI we need a little bit more work, which fortunately can be encapsulated in custom view and some helpers. We’ll use our new shiny tool - Combine to model fetching data.

struct ImageLoadingView: View {
    @ObservedObject var imageLoader: ImageLoader
    
    init(url: URL) {
        imageLoader = ImageLoader(url: url)
    }
    
    var body: some View {
        ZStack {
            if imageLoader.image != nil {
                Image(uiImage: imageLoader.image!)
                    .resizable()
            }
        }
        .onAppear(perform: imageLoader.load)
        .onDisappear(perform: imageLoader.cancel)
    }
}

final class ImageLoader: ObservableObject {
    enum ImageLoadingError: Error {
        case incorrectData
    }

    @Published private(set) var image: UIImage? = nil
    
    private let url: URL
    private var cancellable: AnyCancellable?
    
    init(url: URL) {
        self.url = url
    }
    
    deinit {
        cancellable?.cancel()
    }
    
    func load() {
        cancellable = URLSession
            .shared
            .dataTaskPublisher(for: url)
            .tryMap { data, _ in
                guard let image = UIImage(data: data) else {
                    throw ImageLoadingError.incorrectData
                }
                
                return image
            }
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { _ in },
                receiveValue: { [weak self] image in
                    self?.image = image
                }
            )
    }
    
    func cancel() {
        cancellable?.cancel()
    }
}

Basically, we have a view which is backed by BindingObject - ImageLoader. ImageLoader loads the image and notifies the view by firing willChange. Then view redraws and displays downloaded image.

You’re probably asking yourself right now - “Why this guy even bothered writing this post? I’ve seen Chris Eidhof and many others do that”. Yes, that’s true 😅, but I promise it’s not the end. To be honest, I lied at the beginning. This post is not entirely about SwiftUI. Moreover, you will barely see any SwiftUI-related code for the rest of this post!.

Above approach is fine, but has one drawback though. What if we wanted to sometimes load image from url and sometimes from data or maybe just pass UIImage?

Making it generic

Let’s introduce a protocol:

protocol ImageLoadable {
    func loadImage() -> AnyPublisher<UIImage, Error>
}

ImageLoadable is a simple protocol which hides the work behind loading image from given type. It can be adopted by URL:

extension URL: ImageLoadable {
    enum ImageLoadingError: Error {
        case incorrectData
    }

    func loadImage() -> AnyPublisher<UIImage, Error> {
        URLSession
            .shared
            .dataTaskPublisher(for: self)
            .tryMap { data, _ in
                guard let image = UIImage(data: data) else {
                    throw ImageLoadingError.incorrectData
                }
                
                return image
            }
            .eraseToAnyPublisher()
    }
}

or UIImage:

extension UIImage: ImageLoadable {
    func loadImage() -> AnyPublisher<UIImage, Error> {
        return Just(self)
            // Just's Failure type is Never
            // Our protocol expect's it to be Error, so we need to `override` it
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
}

or maybe our own custom type:

// ImagePath - descriptor of image localization on backend. 
// Can be used when model from backend contains only path to image and not the full URL
struct ImagePath: ImageLoadable {
    static let baseUrl = URL(string: "https://example.com")!

    let path: String

    func loadImage() -> AnyPublisher<UIImage, Error> {
        let fullUrl = Self.baseUrl.appendingPathComponent(path)
        
        // We can reuse loadImage() implementation from URL
        return fullUrl.loadImage()
    }
}

We can now refactor our ImageLoadingView and ImageLoader to use ImageLoadable protocol:

final class ImageLoader: ObservableObject {
    @Published private(set) var image: UIImage? = nil
    
    private let loadable: ImageLoadable
    private var cancellable: AnyCancellable?
    
    init(loadable: ImageLoadable) {
        self.loadable = loadable
    }
    
    deinit {
        cancellable?.cancel()
    }
    
    func load() {
        cancellable = loadable
            .loadImage()
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { _ in },
                receiveValue: { [weak self] image in
                    self?.image = image
                }
            )
    }
    
    func cancel() {
        cancellable?.cancel()
    }
}

struct ImageLoadingView: View {
    @ObservedObject var imageLoader: ImageLoader
    
    init(image: ImageLoadable) {
        imageLoader = ImageLoader(loadable: image)
    }
    
    var body: some View {
        ZStack {
            if imageLoader.image != nil {
                Image(uiImage: imageLoader.image!)
                    .resizable()
            }
        }
        .onAppear(perform: imageLoader.load)
        .onDisappear(perform: imageLoader.cancel)
    }
}

This allows us to use ImageLoadingView with any type conforming to ImageLoadable:

ImageLoadingView(image: URL(string: "https://example.com/image.jpg")!)

ImageLoadingView(image: UIImage(named: "AssetsImage"))

ImageLoadingView(image: ImagePath("path-to-image.jpg")) 

ImageLoadable in the wild

We managed to create abstraction for loading images. Unfortunately, it introduces some problems.

Imagine we are creating pet browsing app. It has the main model - Pet struct:

struct Pet {
    let name: String
    let age: Int
    let image: URL
}

We can already display pet’s image by passing it to ImageLoadingView. But what if we wanted to mock some pets by using images from Assets? To do that, we need to alter our model a bit by replacing URL with ImageLoadable:

struct Pet {
    let name: String
    let age: Int
    let image: ImageLoadable
}

Now we can create our pets in multiple ways:

let remoteImagePet = Pet(name: "Dot", age: 2, image: URL(string: "https://example.com/dot.jpg")!)

let localImagePet = Pet(name: "Rex", age: 5, image: UIImage(named: "Rex"))

It would be nice to sometimes compare pets, wouldn’t it? To make it possible, we need to conform to Equatable protocol. Since Swift 4.1, compiler can automatically generate Equatable conformance when type allows it (i.e. all its properties are Equatable). Sadly, because of the fact that image property is a protocol now, compiler won’t automatically generate the conformance. We would need to write all the needed code ourselves. We don’t want to do that.

You might think that making ImageLoadable inherit from Equatable will fix the problem. Quite the opposite. If we define ImageLoadable as

protocol ImageLoadable: Equatable {
    func loadImage() -> AnyPublisher<UIImage, Error>
}

we won’t be able to use it as property type in our models. Compiler will complain:

There is a solution for this, the dreaded type erasure 😱.

We won’t make ImageLoadable inherit from Equatable. We’ll introduce wrapper type, concrete one, not a protocol, which will conform to Equatable and could be used in place of ImageLoadable in our models. We still need a way to compare two ImageLoadable’s though. Let’s introduce one more requirement to the protocol:

protocol ImageLoadable {
    func loadImage() -> AnyPublisher<UIImage, Error>
    
    func equals(_ other: ImageLoadable) -> Bool
}

equals method is more lenient version of == operator from Equatable protocol. It allows to compare any ImageLoadables, not only those which are of the same concrete type. We will leverage Equatable protocol to supply default implementation of this method for all ImageLoadables which also implement Equatable. Basically all types which might implement ImageLoadable are either already conforming to this protocol (like URL or UIImage) or can be made to conform to it (like our custom ImagePath).

extension ImageLoadable where Self: Equatable {
    func equals(_ other: ImageLoadable) -> Bool {
        return other as? Self == self
    }
}

Our default implementation tries to cast other operand to self’s type and compare it. If operands are of different types, then comparision always returns false.

Armed with equals method, we can now create type-erased wrapper for ImageLoadable. We’ll stay with standard naming convention and name it AnyImageLoadable:

struct AnyImageLoadable: ImageLoadable, Equatable {
    private let loadable: ImageLoadable
    
    init(_ loadable: ImageLoadable) {
        self.loadable = loadable
    }
    
    func loadImage() -> AnyPublisher<UIImage, Error> {
        return loadable.loadImage()
    }
    
    static func ==(lhs: AnyImageLoadable, rhs: AnyImageLoadable) -> Bool {
        return lhs.loadable.equals(rhs.loadable)
    }
}

Now, let’s refactor Pet to use AnyImageLoadable:

struct Pet: Equatable {
    let name: String
    let age: Int
    let image: AnyImageLoadable
}

We can also make it Equatable now and compiler won’t complain about missing == operator, because it’s able to synthesize it for us.

The only drawback is that we need to wrap any URL or UIImage (or any other ImageLoadable) passed to image property into AnyImageLoadable:

let remoteImagePet = Pet(name: "Dot", age: 2, image: AnyImageLoadable(URL(string: "https://example.com/dot.jpg")!))

To make it more concise, let’s add small extension:

extension ImageLoadable {
    func any() -> AnyImageLoadable {
        return AnyImageLoadable(self)
    }
}

We can now shorten above Pet declaration to:

let remoteImagePet = Pet(name: "Dot", age: 2, image: URL(string: "https://example.com/dot.jpg")!.any())

I think it’s small price to pay for the flexibility it gives us.

Last missing feature

Our ImageLoadable is almost production ready, but is missing one big feature - decodability. Since Swift 3 our lives are much easier because of Decodable and Encodable protocols. For most types, declaring them conform to Decodable is enough to allow us to decode them from JSON or any other supported format. Similar to Equatable, compiler will synthesize the conformance, when all properties are also Decodable.

Right now, if we try to make our Pet Decodable, we’ll get an error:

Type 'Pet' does not conform to protocol 'Decodable'

We need to make AnyImageLoadable Decodable to allow compiler generate decodability for our models.

But how? How do we decode type-erased type? Should it be decoded from url-like string or maybe some json dictionary?

We should not hardcode this and make it flexible instead.

To do that, we’ll refactor AnyImageLoadable to be generic. It will wrap existing, already decodable ImageLoadable type, and use it’s as its internal loadable when being decoded:

struct AnyImageLoadable<WhenDecodedLoadable: ImageLoadable & Decodable>: ImageLoadable, Equatable, Decodable {
    private let loadable: ImageLoadable
    
    init(_ loadable: ImageLoadable) {
        self.loadable = loadable
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        
        loadable = try container.decode(WhenDecodedLoadable.self)
    }
    
    func loadImage() -> AnyPublisher<UIImage, Error> {
        return loadable.loadImage()
    }
    
    static func ==(lhs: AnyImageLoadable, rhs: AnyImageLoadable) -> Bool {
        return lhs.loadable.equals(rhs.loadable)
    }
}

One more thing we need to do is alter our any() extension a bit. We need to add some generics to make it work again. Fortunately, because of type inference, its usage won’t change in most cases:

extension ImageLoadable {
    func any<T>() -> AnyImageLoadable<T> {
        return AnyImageLoadable<T>(self)
    }
}

Let’s see our creation in use! Once again, let’s refactor Pet, this time to be Decodable. When decoded, we want image to be decoded from URL:

struct Pet: Equatable, Decodable {
    let name: String
    let age: Int
    let image: AnyImageLoadable<URL>
}

This way, we still can init Pet as before:

let remoteImagePet = Pet(name: "Dot", age: 2, image: URL(string: "https://example.com/dot.jpg")!.any())

let localImagePet = Pet(name: "Rex", age: 5, image: UIImage(named: "Rex").any())

but also decode it from e.g. json:

let json = #"""
{
    "name": "Dot",
    "age": 2,
    "image": "https://example.com/dot.jpg"
}
"""#

let jsonData = json.data(using: .utf8)!
let decodedPet = try! JSONDecoder().decode(Pet.self, from: jsonData)

print(decodedPet)
// Pet(name: "Dot", age: 2, image: AnyImageLoadable<Foundation.URL>(loadable: https://example.com/dot.jpg))

You can find full working example as a Playground named ImageLoadable here. I encourage you to play around with it and see for yourself if it could solve your problems with loading images and mocking them. Also, this approach can be easily ported to UIImageView. It’s also not tied to Combine. It can be either replaced with RxSwift or just ‘old’, good closures.

There are also couple of improvements to be made, e.g. decoupling loadImage for URL from URLSession.shared. We can do that by injecting URLSession with ambient context.

I hope you enjoyed this bit lengthy post and see you in the next one!

UPDATE: Solving problems with image not loading after updating parent view

One of commenters pointed out that current implementations of ImageLoadingView and ImageLoader won’t show image anymore after parent of ImageLoadingView updates. That’s caused by the view and loader being created again, but onAppear closure not getting called again (as view on screen has been visible all the time). I struggled a bit trying to come up with solution for this and finally stumbled upon implementation by Thomas Ricouard in his great project MovieSwiftUI.

Simplified version of this approach looks like this:

final class ImageLoader: ObservableObject {
    var objectWillChange: AnyPublisher<UIImage?, Never> = Empty().eraseToAnyPublisher()
    
    @Published private(set) var image: UIImage? = nil
    
    private let loadable: ImageLoadable
    private var cancellable: AnyCancellable?
    
    static var loadCount = 0
    
    init(loadable: ImageLoadable) {
        self.loadable = loadable

        self.objectWillChange = $image.handleEvents(receiveSubscription: { [weak self] sub in
            self?.load()
        }, receiveCancel: { [weak self] in
            self?.cancellable?.cancel()
        }).eraseToAnyPublisher()
    }
    
    private func load() {
        guard image == nil else { return }
        
        cancellable = loadable
            .loadImage()
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { _ in },
                receiveValue: { [weak self] image in
                    self?.image = image
                }
            )
    }
    
    func cancel() {
        cancellable?.cancel()
    }
}

struct ImageLoadingView: View {
    @ObservedObject private var imageLoader: ImageLoader
    
    init(image: URL) {
        imageLoader = ImageLoader(loadable: image)
    }
    
    var body: some View {
        return ZStack {
            if imageLoader.image != nil {
                Image(uiImage: imageLoader.image!)
                    .resizable()
            }
        }
    }
}

In this approach, image load is triggered when view subscribes to changes in ImageLoader. That way we are not constrained to onAppear method. Each time view is recreated, new loader is created, which then loads the image. This might sound wasteful and indeed is - there is no caching of loaded images. In MovieSwiftUI this is solved by managing pool of ImageLoader’s and reusing ones for already loaded images. In our case I would prefer hiding caching (if needed) in each ImageLoadable implementation. E.g. URL could delegate its loadImage() logic to some service object which fetches images and caches them for next loads.

Unfortunately there are probably couple other cases which are not covered by above implementation. I’m also very happy this post sparkled this discussion on Twitter which can be shortened to one sentence: ‘Async image loading should be built-in feature of SwiftUI’. We still have some time before SwiftUI goes out of beta - maybe our wishes will be fulfilled. On the other hand, SwiftUI is already backed by strong community which will surely solve these problems if not addressed by Apple.

comments powered by Disqus