Async Downloading of Images in SwiftUI

SwiftUI makes it easy to display images, but what if those images are remote? Async loading of images is something that's been covered a lot in the past on iOS, so much so that it could almost be expected to be built in to SwiftUI.

Not the Not Invented Here syndrome

There's lots of tutorials out there with SwiftUI and Combine. But... some didn't work, or didn't compile with the latest version of Xcode (even after @BindableObject and all the other name changes), or were part of a massive framework with additional functionality (such as caching that wasn't required yet), or came with a list of CocoaPods that made me worry that npm was being installed in the background...

So it seemed like a good idea—or convenient excuse—to build something from scratch to experiment with some ideas.

Starting Point

Let's start off with a simple SwiftUI app, TestAsync.

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello World")
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Getting the data from another object

We're going to want to display remote images in lots of places so we'll put the image loading functionality Into a separate object. Ideally that object will do the heavy lifting of actually downloading the image and will somehow let us know when it is ready. The new shiny way for doing this is with ObservableObjects, and in particular variables that they publish.

The syntax around observable objects has changed a bit throughout the beta process: BindableObject became ObservableObject, and the necessity to write the boilerplate code around objectWillChange has been removed through the use of the @Published property.

https://developer.apple.com/documentation/ios_ipados_release_notes/ios_13_release_notes

import SwiftUI
import Combine

final class ImageLoader: ObservableObject {
    @Published var image = "image goes here"
}

struct RemoteImage: View {
    @ObservedObject private var imageLoader = ImageLoader()
    
    var body: some View {
        Text("Hello \\(imageLoader.image)")
    }
}

struct ContentView: View {
    var body: some View {
        RemoteImage()
    }
}

This is nice—our ContentView contains a custom RemoteImage view which observes an ImageLoader which we've just created. The ImageLoader has a @Published variable which we display in text. Okay, so you might have noticed that our image loader doesn't play with images yet, just strings—an image may be worth a thousand words, but a couple of words are a lot easier to debug.

Simulating the loading of an image

We know we're going to need a loadImage() function, and we assume it is going to take a non-zero amount of time to complete, so let's simulate it for now by waiting three seconds and then changing the value of image.

final class ImageLoader: ObservableObject {
    @Published var image = "loading image"
 
    func loadImage() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            self.image = "image loaded!"
        }
    }
}

We'll have to call this loadImage function when our RemoteImage view appears, using onAppear(). Really wanted to avoid doing anything in init() since that we want to make sure that this function gets called every time the view appears, not just when it is first init'd.

The .layoutPriority(2) helps with the long line for the URL, otherwise it gets truncated.

struct RemoteImage: View {
    @ObservedObject private var imageLoader = ImageLoader()
        
    var body: some View {
        VStack {
            Text("URL = \\(self.url)")
                .layoutPriority(2)
            Text("\\(imageLoader.image)")
        }
        .onAppear() {
            self.imageLoader.loadImage()
        }
    }
}

When our RemoteImage view appears, we call the loadImage() function on our imageLoader, which starts the three-second timer, after which it changes the value of image. Since we're observing imageLoader, we get our view updated whenever its @Published variable changes.

Passing in a URL

Next we should really give the RemoteImage a URL so it knows which image to display. We'll just pass in the URL (no error checking for now!) and display it.

final class ImageLoader: ObservableObject {
    @Published var image = "loading image"
    
    func loadImage(url: URL) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            self.image = "image loaded from \\(url.absoluteString)"
        }
    }
}

struct RemoteImage: View {
    var url: URL
    
    @ObservedObject private var imageLoader = ImageLoader()
    
    var body: some View {
        VStack {
            Text("URL = \\(self.url)")
                .layoutPriority(2)
            Text("Hello \\(imageLoader.image)")
        }
        .onAppear() {
            self.imageLoader.loadImage(url: self.url)
        }
    }
}

struct ContentView: View {
    var body: some View {
        RemoteImage(url: URL(string: "---insert URL string here---")!)
    }
}

Displaying an image

Okay, so now we know that we're passing in the URL at the right point, and that we're able to respond to published changes from our ImageLoader. How about we move away from returning strings and maybe actually display an image? Okay, so maybe not a remote image just yet, how about ones created from the SF Symbols set?

final class ImageLoader: ObservableObject {
    @Published var image: UIImage?
    
    func loadImage(url: URL) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
            self.image = UIImage(systemName: "cloud")
        }
    }
}

struct RemoteImage: View {
    var url: URL
    
    @ObservedObject private var imageLoader = ImageLoader()
        
    var body: some View {
         Image(uiImage: self.imageLoader.image ?? UIImage(systemName: "photo")!)
        .onAppear() {
            self.imageLoader.loadImage(url: self.url)
        }
    }
}

Our RemoteImage view now includes a SwiftUI Image which tries to display an image from the imageLoader but if it is nil it'll draw the photo placeholder instead. After three seconds our imageLoader should update its image, which is published, so we should now display the cloud icon in our RemoteImage.

We're using UIImage to represent the images, making this incompatible with macOS—at some point we might have to look at changing from UIImage if we're going to get this code to run cross-platform.

And, yes, not so happy about having to force unwrap UIImage(systemName: "photo")! for the placeholder view—wishing that instead of strings they were referenced by an enum so unknown images would be picked up at compile time.

Actually downloading the image

Do we really want to? That involves networking and talking to the outside world... Okay, then. We start with our old friend URLSession—"an object that coordinates a group of related network data transfer tasks", and we're happy to get the shared singleton session object, since this gives us good default behavior.

Now in the past we'd have gone for dataTask(with:completionHandler:)

https://developer.apple.com/documentation/foundation/urlsession/1410330-datatask

But there's the tempting dataTaskPublisher(for:) which sounds interesting—after all publisher and subscribers are the cool new things.

https://developer.apple.com/documentation/foundation/urlsession/3329708-datataskpublisher

So we give the dataTaskPublisher the URL to fetch, and it will publish data when the task completes, or terminates if the task fails with an error. We can then use the sink subscriber to take the published results from dataTaskPublisher, where we should handle errors. We use an AnyCancellable variable to keep the URLSession object in scope and potentially allow us to cancel it.

final class ImageLoader: ObservableObject {
    @Published var image: UIImage?
    
    var cancellable: AnyCancellable?
    
    func loadImage(url: URL) {
        print("load image url \\(url)")
        cancellable = URLSession
            .shared
            .dataTaskPublisher(for: url)
            .sink(receiveCompletion: { (completion) in
                switch completion {
                    case .failure(let error):
                        print(error)
                    case .finished:
                        print("finished")
                }
            }, receiveValue: { (data, response) in
                print("received value)")
                print(response)
            })
    }
}

If we try this using different URLs (one to a known-good image, one to a non-existent image) then we should see different responses being printed out, including the appropriate status code.

Display the image

The trick now is to try and create a UIImage from the data we've received in the receiveValue closure, and set our image to that UIImage. And because we're updating the UI we have to do this on the main thread.

     func loadImage(url: URL) {
        print("load image url \\(url)")
        cancellable = URLSession
            .shared
            .dataTaskPublisher(for: url)
            .sink(receiveCompletion: { (completion) in
                switch completion {
                    case .failure(let error):
                        print(error)
                    case .finished:
                        print("finished")
                }
            }, receiveValue: { (data, response) in
                DispatchQueue.main.async {
                    print("received value)")
                    print(response)
                    self.image = UIImage(data: data)
                }
            })
    }

Cleaning up

For now we're not doing useful anything in the receiveCompletion closure (we'll just quietly fail if the image cannot be downloaded), so we can remove that and the print statements to get a much smaller function.

final class ImageLoader: ObservableObject {
    @Published var image: UIImage?
    
    var cancellable: AnyCancellable?
    
    func loadImage(url: URL) {
        cancellable = URLSession
            .shared
            .dataTaskPublisher(for: url)
            .sink(receiveCompletion: { (completion) in
             }, receiveValue: { (data, response) in
                DispatchQueue.main.async {
                    self.image = UIImage(data: data)
                }
            })
    }
}

We also make a couple of changes to the image view to make the image resizable (the caller can always set a .frame), and also to preserve aspect ratio and fit within the frame, rather than filling—although this messes up the placeholder image, so we're going to have to come back and fix that later when we find a way of conditionally setting properties in SwiftUI that we actually like.

struct RemoteImage: View {
    var url: URL
    
    @ObservedObject private var imageLoader = ImageLoader()
        
    var body: some View {
        Image(uiImage: self.imageLoader.image ?? UIImage(systemName: "photo")!)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .onAppear() {
                self.imageLoader.loadImage(url: self.url)
            }
    }
}

And we can call it from other views, passing in the URL:

struct ContentView: View {
    var body: some View {
        RemoteImage(url: URL(string: "---insert URL string here---")!)
    }
}

Summary

This code is just a starting point—there's no error handling, and no caching, but it does sort of work and it was a fun experiment to use some of Combine's features.