/ SWIFTUI

Asynchronous Image Loading from URL in SwiftUI

Downloading and displaying images from a remote URL is a common task in iOS and macOS engineering. Although SwiftUI doesn’t provide a built-in solution for it, we can come up with own implementation by utilizing rich APIs available in Apple system frameworks. In this article, let’s implement an AsyncImage SwiftUI component that bridges this gap.

Basic knowledge of SwiftUI and Combine is required for this article. Getting Started with Combine and Apple SwiftUI tutorials will get you up to speed.

Preparing Initial Design

The purpose of the AsyncImage view is to display an image provided its URL. It depends on ImageLoader that fetches an image from the network and emits image updates via a Combine publisher.

Let’s begin with designing the loader:

import SwiftUI
import Combine
import Foundation

class ImageLoader: ObservableObject {
    @Published var image: UIImage?
    private let url: URL

    init(url: URL) {
        self.url = url
    }

    deinit {
        cancel()
    }
    
    func load() {}

    func cancel() {}
}

The Combine’s way of making a model observable is by conforming to the ObservableObject protocol. In order to bind image updates to a view, we add the @Published property wrapper.

Next, implement the AsyncImage view:

struct AsyncImage<Placeholder: View>: View {
    @StateObject private var loader: ImageLoader
    private let placeholder: Placeholder

    init(url: URL, @ViewBuilder placeholder: () -> Placeholder) {
        self.placeholder = placeholder()
        _loader = StateObject(wrappedValue: ImageLoader(url: url))
    }

    var body: some View {
        content
            .onAppear(perform: loader.load)
    }

    private var content: some View {
        placeholder
    }
}

Here are the takeaways:

  1. We bind AsyncImage to image updates by means of the @StateObject property wrapper. This way, SwiftUI will automatically rebuild the view every time the image changes. We pick @StateObject over @ObservedObject and @EnvironmentObject since we want the view to manage image loader’s lifecycle. SwiftUI automatically keeps image loader alive as long as AsyncImage remains visible, and releases the image loader when the view is not needed anymore.
  2. In the body property, we start image loading when AsyncImage’s body appears. There is no need to cancel image loading explicitly in view’s onDisappear() since SwiftUI does this automatically for @StateObjects.
  3. For now, the body contains a placeholder instead of an actual image.

Loading Image Asynchronously

Let’s implement image loading and cancellation. We’ll use the promise-based solution from the Combine framework:

Learn more about Futures and Promises in Combine here.

class ImageLoader: ObservableObject {
    // ...
    private var cancellable: AnyCancellable?

    func load() {
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map { UIImage(data: $0.data) }
            .replaceError(with: nil)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in self?.image = $0 }
    }
    
    func cancel() {
        cancellable?.cancel()
    }
}

Then update AsyncImage to display an image or a placeholder:

struct AsyncImage<Placeholder: View>: View {
    // ...
    private var content: some View {
        Group {
            if loader.image != nil {
                Image(uiImage: loader.image!)
                    .resizable()
            } else {
                placeholder
            }
        }
    }
}

Lastly, to test our component, add the following code to ContentView:

struct ContentView: View {
    let url = URL(string: "https://image.tmdb.org/t/p/original/pThyQovXQrw2m0s9x82twj48Jq4.jpg")!
    
    var body: some View {
        AsyncImage(
            url: url,
            placeholder: Text("Loading ...")
        ).aspectRatio(contentMode: .fit)
    }
}

The result looks next:

Loading Images Asynchronously from URL in SwiftUI

Caching Images

First, create a thin abstraction layer on top of NSCache:

protocol ImageCache {
    subscript(_ url: URL) -> UIImage? { get set }
}

struct TemporaryImageCache: ImageCache {
    private let cache = NSCache<NSURL, UIImage>()
    
    subscript(_ key: URL) -> UIImage? {
        get { cache.object(forKey: key as NSURL) }
        set { newValue == nil ? cache.removeObject(forKey: key as NSURL) : cache.setObject(newValue!, forKey: key as NSURL) }
    }
}

Second, add caching to ImageLoader:

class ImageLoader: ObservableObject {
    // ...
    private var cache: ImageCache?

    init(url: URL, cache: ImageCache? = nil) {
        self.url = url
        self.cache = cache
    }
    
    func load() {
        if let image = cache?[url] {
            self.image = image
            return
        }
        
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map { UIImage(data: $0.data) }
            .replaceError(with: nil)
            .handleEvents(receiveOutput: { [weak self] in self?.cache($0) })
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in self?.image = $0 }
    }
    
    private func cache(_ image: UIImage?) {
        image.map { cache?[url] = $0 }
    }
}

Third, we need a way of making image cache accessible for any view that needs to load and display remote images. SwiftUI way of passing global dependencies is by means of an environment.

Environment is essentially a dictionary with app-wide preferences. SwiftUI passes it automatically from the root view to its children.

Here is how we can add an image cache to the environment:

struct ImageCacheKey: EnvironmentKey {
    static let defaultValue: ImageCache = TemporaryImageCache()
}

extension EnvironmentValues {
    var imageCache: ImageCache {
        get { self[ImageCacheKey.self] }
        set { self[ImageCacheKey.self] = newValue }
    }
}

The default image cache will be created when we access it for the first time via the @Environment property wrapper.

Fourth, add the image cache to the AsyncImage initializer:

struct AsyncImage<Placeholder: View>: View {
    // ...
    init(url: URL, @ViewBuilder placeholder: () -> Placeholder) {
        self.placeholder = placeholder()
        _loader = StateObject(wrappedValue: ImageLoader(url: url, cache: Environment(\.imageCache).wrappedValue))
    }
    // ...
}

The important takeaway from the above snippet is that we read the image cache from the AsyncImage’s environment, and pass it directly to the ImageLoader’s initializer.

Lastly, add this code to your ContentView to see caching in action:

struct ContentView: View {
    let url = URL(string: "https://image.tmdb.org/t/p/original/pThyQovXQrw2m0s9x82twj48Jq4.jpg")!
    @State var numberOfRows = 0

    var body: some View {
        NavigationView {
            list.navigationBarItems(trailing: addButton)
        }
    }

    private var list: some View {
        List(0..<numberOfRows, id: \.self) { _ in
            AsyncImage(url: self.url, placeholder: { Text("Loading ...") })
                .frame(minHeight: 200, maxHeight: 200)
                .aspectRatio(2 / 3, contentMode: .fit)
        }
    }

    private var addButton: some View {
        Button(action: { self.numberOfRows += 1 }) { Image(systemName: "plus") }
    }
}

The result looks next:

Loading Images Asynchronously from URL in SwiftUI

Finalizing Image Loading

There are two subtle problems left:

  1. ImageLoader’s load() method is not idempotent.
  2. Image caching is not thread-safe.

Let’s solve the first issue this by adding a loading state:

class ImageLoader: ObservableObject {    
    // ..
    // 1.
    private(set) var isLoading = false
    
    func load() {
        // 2.
        guard !isLoading else { return }

        if let image = cache?[url] {
            self.image = image
            return
        }
        
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .map { UIImage(data: $0.data) }
            .replaceError(with: nil)
            // 3.
            .handleEvents(receiveSubscription: { [weak self] _ in self?.onStart() },
                          receiveOutput: { [weak self] in self?.cache($0) },
                          receiveCompletion: { [weak self] _ in self?.onFinish() },
                          receiveCancel: { [weak self] in self?.onFinish() })
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in self?.image = $0 }
    }
    
    private func onStart() {
        isLoading = true
    }
    
    private func onFinish() {
        isLoading = false
    }

    // ..
}

Here is what we are doing:

  1. Add isLoading property that indicates current loading status.
  2. Exit early if image loading is already in progress.
  3. Handle subscription lifecycle events and update isLoading accordingly.

Then we add a serial image processing queue that takes care of thread safery issue. In the load() method, we subscribe on that queue:

class ImageLoader: ObservableObject {
    // ..
    private static let imageProcessingQueue = DispatchQueue(label: "image-processing")
    
    func load() {
        // ..
        cancellable = URLSession.shared.dataTaskPublisher(for: url)
            .subscribe(on: Self.imageProcessingQueue)
            // ..
    }
}

As a finishing touch, let’s make AsyncImage more reusable by passing image configuration from the outside:

struct AsyncImage<Placeholder: View>: View {
    // ...
    private let image: (UIImage) -> Image
    
    init(
        url: URL,
        @ViewBuilder placeholder: () -> Placeholder,
        @ViewBuilder image: @escaping (UIImage) -> Image = Image.init(uiImage:)
    ) {
        self.placeholder = placeholder()
        self.image = image
        _loader = StateObject(wrappedValue: ImageLoader(url: url, cache: Environment(\.imageCache).wrappedValue))
    }

    // ...

    private var content: some View {
        Group {
            if loader.image != nil {
                image(loader.image!)
            } else {
                placeholder
            }
        }
    }
}

To see AsyncImage in action, add the following code, that displays a list of movie posters, to your ContentView:

let posters = [
    "https://image.tmdb.org/t/p/original/pThyQovXQrw2m0s9x82twj48Jq4.jpg",
    "https://image.tmdb.org/t/p/original/vqzNJRH4YyquRiWxCCOH0aXggHI.jpg",
    "https://image.tmdb.org/t/p/original/6ApDtO7xaWAfPqfi2IARXIzj8QS.jpg",
    "https://image.tmdb.org/t/p/original/7GsM4mtM0worCtIVeiQt28HieeN.jpg"
].map { URL(string: $0)! }

struct ContentView: View {
    var body: some View {
         List(posters, id: \.self) { url in
             AsyncImage(
                url: url,
                placeholder: { Text("Loading ...") },
                image: { Image(uiImage: $0).resizable() }
             )
            .frame(idealHeight: UIScreen.main.bounds.width / 2 * 3) // 2:3 aspect ratio
         }
    }
}

The result looks next:

Loading Images Asynchronously from URL in SwiftUI

Source code

You can find the final project here. It is published under the “Unlicense”, which allows you to do whatever you want with it.

Performance Note

Downloading large images with a data task may result in memory pressure, as measured by Tibor. In this case, I suggest looking into download task and downloadTaskPublisher. Make sure not to optimize prematurely and do your measurements before tweaking the performance.


Thanks for reading!

If you enjoyed this post, be sure to follow me on Twitter to not miss any new content.

Vadim Bulavin

Creator of Yet Another Swift Blog. Senior iOS Engineer at Pluto TV. Coding for fun since 2008, for food since 2012.

Follow