Remote images in SwiftUI

Almost every iOS app needs to display images, you can have them in your app’s bundle or you load them from an external URL, and that’s what this article is all about.
As usual you can find the source code on GitHub at this link

Building the UI

In the example I’m presenting a list of stargazers from a given GitHub repository, and I want to show their avatar and the name. The view is really simple


struct StargazersView: View {
    var stargazers:[User] = []
    
    var body: some View {
        List(stargazers) { stargazer in
            HStack {
                ImageView(withURL: stargazer.avatarUrl)
                Text(stargazer.login)
            }
        }
    }
}

struct ImageView: View {
    @ObservedObject var imageLoader:ImageLoader
    @State var image:UIImage = UIImage()
    
    init(withURL url:String) {
        imageLoader = ImageLoader(urlString:url)
    }
    
    var body: some View {
        VStack {
            Image(uiImage: image)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:100, height:100)
        }.onReceive(imageLoader.didChange) { data in
            self.image = UIImage(data: data) ?? UIImage()
        }
    }
}

Let’s take a look at ImageView. I need a UIImage that I’ll convert to a SwiftUI Image as you can see with the call to Image(uiImage:image) and I initialise it with an empty image, but you can set something from your asset catalog, for example an empty avatar image.
We’ll take a look at ImageLoader, but first let’s see how our view deals with the image being fetched and become available after the view is loaded. At init we create an instance of ImageLoader with the url we want to fetch, and this is an ObservedObject. We need to use a type conforming to ObervableObject in order to use it as @ObservedObject, and this object will need to notify the view when the data is ready. I’ll show you two ways to do that, a PassthroughtSubject and a Published property wrapper.

ImageLoader

Let’s see the PassthroughtSubject implementation first


class ImageLoader: ObservableObject {
    var didChange = PassthroughSubject()
    var data = Data() {
        didSet {
            didChange.send(data)
        }
    }
    
    init(urlString:String) {
        guard let url = URL(string: urlString) else { return }
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data else { return }
            DispatchQueue.main.async {
                self.data = data
            }
        }
        task.resume()
    }
}

We need to set a data variable, that’s the remote image we’re loading. We can initialise it with empty data, so obviously not a valid image. After we fetch the remote data via URLSession dataTask we can change data, and as you can see on didSet we call send on didChange, a PassthroughSubject. Our ImageView registered itself as a receiver of this publisher, so when we set the data variable the receiver is notified and can read it, then try to create a UIImage from it.
Note that you can use .onReceive to deal with a publisher, we could have listened to the publisher on the init function as well.
If you’re interested in how a publisher works please refer to my other articles Combine first Example and Networking with Combine.

Using @Published

There is a second, simpler way to implement ImageLoader in order to use it as ObservedObject. On GitHub you’ll find only the first implementation, so if you like the alternative implementation you need to copy and past from this article


class ImageLoader: ObservableObject {
    @Published var data:Data?
    
    init(urlString:String) {
        guard let url = URL(string: urlString) else { return }
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data else { return }
            DispatchQueue.main.async {
                self.data = data
            }
        }
        task.resume()
    }
}

As you can see data is still the variable we want to update, but by using the @Published property wrapper we don’t need to create our own publisher like a PassthroughSubject and notify our subscribers. The property wrapper takes care of it.
Here’s the alternative version of ImageView.


var body: some View {
        VStack {
            Image(uiImage: imageLoader.data != nil ? UIImage(data:imageLoader.data!)! : UIImage())
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:100, height:100)
        }
    }

Here we can check the data property of imageLoader directly. If it is nil we use an empty UIImage, otherwise we can use data to create a UIImage. Once data is set @Published will notify ImageView that will reload Image using the updated version of data from imageLoader.
I don’t like testing for nil, so a third option could be having a bool as the published value in ImageLoader, like this



class ImageLoader: ObservableObject {
    @Published var dataIsValid = false
    var data:Data?
    
    init(urlString:String) {
        guard let url = URL(string: urlString) else { return }
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            guard let data = data else { return }
            DispatchQueue.main.async {
                self.dataIsValid = true
                self.data = data
            }
        }
        task.resume()
    }
}

// ImageView

func imageFromData(_ data:Data) -> UIImage {
    UIImage(data: data) ?? UIImage()
}

var body: some View {
        VStack {
            Image(uiImage: imageLoader.dataIsValid ? imageFromData(imageLoader.data!) : UIImage())
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:100, height:100)
        }
    }

So that’s it, maybe the second implementation is easier and will be the most popular but I like using publishers so I started with the one with Combine, I think it gives more control even if this is a simple example and it feels overkill to use a PassthroughSubject.

Happy coding!