Building a CachedAsyncImage using SwiftUI

Introduction

SwiftUI provides us with AsyncImage to asynchronously load images. However, when it comes to loading multiple images, a ScrollView or a ListView will start deallocating images, forcing the images to load again. This makes your UI look glitchy, and far from smooth to the user. This component will allow you to load images in the background, cache them, and replicating SwiftUI's component.

CachedAsyncImage

The CachedAsyncImage is a SwiftUI View that encapsulates the logic for asynchronously loading and displaying images. Receives content and placeholder just as a regular AsyncImage in SwiftUI. Let's take a closer look at the code:

import Foundation
import SwiftUI

struct CachedAsyncImage<Content, Placeholder>: View where Content: View, Placeholder: View {
    @ViewBuilder private let content: (Image) -> Content
    @ViewBuilder private let placeholder: () -> Placeholder
    @ObservedObject var imageLoader: ImageLoader

    init(
        urlString: String,
        @ViewBuilder content: @escaping (Image) -> Content,
        @ViewBuilder placeholder: @escaping () -> Placeholder
    ) {
        imageLoader = ImageLoader(urlString: urlString)
        self.content = content
        self.placeholder = placeholder
    }

    var body: some View {
        if let image = imageLoader.image {
            content(Image(uiImage: image))
        } else {
            placeholder()
        }
    }
}

ImageLoader

The ImageLoader class handles the asynchronous loading of images from a given URL. It utilizes Combine framework's Published property wrapper to update the UI whenever the image is loaded.

import Combine
import UIKit

final class ImageLoader: ObservableObject {

    @Published var image: UIImage?

    private var urlString: String
    private var task: URLSessionDataTask?

    init(urlString: String) {
        self.urlString = urlString
        loadImage()
    }

    private func loadImage() {
        if let cachedImage = ImageCache.shared.get(forKey: urlString) {
            self.image = cachedImage
            return
        }

        guard let url = URL(string: urlString) else { return }

        URLSession.shared.dataTask(with: url) { [weak self, urlString] data, response, error in
            guard let self, let data = data, error == nil else { return }

            DispatchQueue.main.async {
                if let image = UIImage(data: data) {
                    self.image = image
                    ImageCache.shared.set(image, forKey: urlString)
                }
            }
        }
        .resume()
    }
}

ImageCache

As the name implies, the ImageCache class is responsible only for caching images to improve performance and reduce redundant network requests.

import Foundation
import UIKit

final class ImageCache {
    static let shared = ImageCache()
    private let cache = NSCache<NSString, UIImage>()

    private init() {}

    func set(_ image: UIImage, forKey key: String) {
        cache.setObject(image, forKey: key as NSString)
    }

    func get(forKey key: String) -> UIImage? {
        return cache.object(forKey: key as NSString)
    }
}

Putting all together

This example shows how to use CachedAsyncImage with a content closure for displaying the loaded image and a placeholder closure for displaying a loading indicator. Here's the gist if you want to take a look at the source code.


Thanks for reading. I hope you have enjoyed this small piece of code, and if it was useful for you, don’t be shy to leave a like, and a comment. See you next time.