Yakov Manshin

Remote Images in SwiftUI

SwiftUI introduced an entirely new approach toward user interface design and composition. Although not quite suited for use in projects with huge user bases yet (since it only supports Apple’s latest OS releases), SwiftUI is great for small features that users with older operating systems can live without. Besides, using the new framework in minor features is probably the best way of practicing for a greater adoption of SwiftUI in the future.

In the app I consider my pet project, Space Photos, I recently added a new feature built with SwiftUI. It’s a carousel of previously posted photos, which displays below main photo’s description. Here’s what it looks like.

The images displayed in the carousel are naturally fetched from the web. But to my huge surprise, SwiftUI has no built-in support for remote images. Unlike UIKit’s UIImageView, you can’t create an empty SwiftUI image view and populate it with an image later.

The good news is, the solution is pretty simple and fits into 50 lines of code. It’s based on some of the patterns you’re (hopefully) already familiar with: observable objects, Combine publishers, and property wrappers. What’s even better is you can easily set placeholder images for use while the actual image is downloading, and in case download fails. Thus, instead of empty space you’ll see a placeholder icon tailored to your specific app.

The first thing to do is define an “image holder” class your other views will observe (therefore it conforms to ObservableObject).

import Combine
import SwiftUI

@available(iOS 13, *)
final class RemoteImage: ObservableObject {
    
    // 1
    @Published var image: Image
    private let imageURL: URL
    
    // 2
    private var downloader: AnyCancellable?
    
    init(with imageURL: URL) {
        self.imageURL = imageURL
        self.image = Image(uiImage: Constants.fallbackImage)
    }
    
    // 3
    private enum Constants {
        static var fallbackImage: UIImage { UIImage(named: "my_placeholder_image")! }
    }
    
}

Here’s a few notes:

  1. image is prepended with the @Published attribute, which is a property wrapper, to allow objects that make use of image to receive updates whenever image changes;
  2. downloader is the object that actually downloads the image by its URL. I’ll get back to it in a minute;
  3. In the Constants enum, I’ve added a locally stored fallback image that’s displayed in place of an actual image in two cases: (1) before download is completed, and (2) if download fails. Here I use the same image for both situations, but you’re free to define two separate ones.

The only part missing is the downloadImage() method in which the image is fetched from the web. And here it is.

init(with imageURL: URL) {
    self.imageURL = imageURL
    self.image = Image(uiImage: Constants.fallbackImage)
    
    // 1
    downloadImage()
}

private func downloadImage() {
    // 2
    downloader = URLSession.shared.dataTaskPublisher(for: imageURL) // 3
        .map(\.data) // 4
        .compactMap(UIImage.init(data:)) // 5
        .replaceError(with: Constants.fallbackImage) // 6
        .map(Image.init(uiImage:)) // 7
        .assign(to: \.image, on: self) // 8
}
  1. The call to downloadImage() is added to the end of the initializer, so download begins as soon as properties are assigned initial values;
  2. It’s important to keep a reference to the publisher—here, I save it to RemoteImage’s downloader property—otherwise it will be deallocated as soon as the method reaches its end (which is way earlier than the image is downloaded), while the publisher must be alive for as long as RemoteImage is;
  3. If you’ve ever worked with URLSession and, more specifically, URLSessionDataTask, this line looks pretty familiar, except for one thing: instead of dataTask(with:completionHandler:), I use an equivalent method for Combine, dataTaskPublisher(for:). In this method, you don’t handle results in the completion handler, but pass them down the subscription chain;
  4. Data task publisher’s output (received in case of success) consists of two components: the actual response data, and URLResponse with meta information about the network call. I’m only interested in the data, so I use Combine’s map(_:) operator to get rid of everything else;
  5. Since the data received from the web can be corrupted, UIImage initialization has a potential to fail. I use compactMap(_:) to only publish successfully initialized images;
  6. Every Combine publisher defines two types of values: Output and Failure. The former is what you get when the publisher succeeds, and the latter is for when the publisher fails. Failure can be of type Never if the publisher never fails, and that’s a thing to remember. Up to this point, I focused exclusively on the output, but now it’s time to handle the error case. By calling replaceError(with:), I make sure if an error comes, it’s effectively replaced with a fallback value. From now on, all subsequent publishers will have the Never type of Failure;
  7. Right now, the type of Output is UIImage. It’s not a mistake: SwiftUI’s Images cannot be initialized with Data values directly, and that’s another thing I consider SwiftUI design’s flaw. However, the map(_:) operator effortlessly transforms UIImages into Images;
  8. Finally, the resulting value is assigned to RemoteImage’s image property. When it happens, the new value will be published, and every object that uses image will receive it. Such a behavior is possible thanks to the @Published attribute.

When you want to embed an image into your SwiftUI view, there’s just two things to do. Here’s a simple view with just the image.

struct MyView: View {
    
    // 1
    @ObservedObject var myImage = RemoteImage(with: URL(string: "https://i.imgur.com/5sgoFsa.jpg")!)
    
    var body: some View {
        // 2
        myImage.image
    }
    
}
  1. Add a RemoteImage variable to your view and prepend the declaration with the @ObservedObject attribute. Doing so will ensure the view is invalidated and redrawn whenever myImage changes;
  2. Use the image property on myImage to access the Image object.

Here’s the complete RemoteImage code. Just 32 lines.

import Combine
import SwiftUI

@available(iOS 13, *)
final class RemoteImage: ObservableObject {
    
    @Published var image: Image
    private let imageURL: URL
    
    private var downloader: AnyCancellable?
    
    init(with imageURL: URL) {
        self.imageURL = imageURL
        self.image = Image(uiImage: Constants.fallbackImage)
        
        downloadImage()
    }
    
    private func downloadImage() {
        downloader = URLSession.shared.dataTaskPublisher(for: imageURL)
            .map(\.data)
            .compactMap(UIImage.init(data:))
            .replaceError(with: Constants.fallbackImage)
            .map(Image.init(uiImage:))
            .assign(to: \.image, on: self)
    }
    
    private enum Constants {
        static var fallbackImage: UIImage { UIImage(named: "my_placeholder_image")! }
    }
    
}