SwiftUI Generic Image Loading
July 31, 2019
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 ImageLoadable
s, not only those which are of the same concrete type. We will leverage Equatable
protocol to supply default implementation of this method for all ImageLoadable
s 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.