Asynchronously load images with customized AsyncImage view in SwiftUI

AsyncImage is a built-in SwiftUI view that asynchronously downloads and displays an image from a remote URL. It is designed to provide a smooth and performant user experience by downloading images asynchronously in the background while allowing the user to interact with the rest of the app.

AsyncImage Basics

To use AsyncImage, you simply provide a URL to the image you want to display, and AsyncImage takes care of the rest. It will show a placeholder image while the actual image is being downloaded and then update the view with the downloaded image when it’s available.

The simplest way to use it is like so:

 AsyncImage(url: URL(string: "https://example.com/image.jpg")) { image in
    image
        .resizable()
        .aspectRatio(contentMode: .fit)
} placeholder: {
    ProgressView()
}

As you can see in the example above, we provide a URL to the image we want to display and a closure that specifies how the downloaded image should be displayed (in this case, we make it resizable and set its aspect ratio). We also provide a placeholder view to be shown while the image is being downloaded (in this case, a ProgressView).

Why would you need a custom AsyncImage view?

While the built-in AsyncImage view in SwiftUI is quite powerful and versatile, there are times when you may need to create a custom version of the AsyncImage view to meet the specific requirements of your app. For example, in some cases, you may need a custom AsyncImage view that can load and display images from various sources, including remote URLs, local files, and captured images from the device’s camera.

Custom loading behavior

To create a custom AsyncImage view that can handle all three types of images, we can start by defining the ImageLoader that fetches the image from the source and emits image updates to a view.

Handling various sources

Let’s begin with the implementation of the loader:

import SwiftUI
import Combine
import Foundation

// 1
enum ImageSource {
    case remote(url: URL?)
    case local(name: String)
    case captured(image: UIImage)
}

// 2
private class ImageLoader: ObservableObject {
    private let source: ImageSource

    init(source: ImageSource) {
        self.source = source
    }

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

    func cancel() {}
}

Here is a breakdown of what is happening with the code:

  1. Define an enum ImageSource that can take in three different types of image sources: a remote URL, a local file name, and a captured image.
  1. Create an ImageLoader to bind image updates to a view.

Handling different phases of the asynchronous operation

Let’s implement image loading and cancelation.

To provide better control during the load operation, we define an enum AsyncImagePhase (Similar implementation to Apple Documentation) to represent the different phases of an asynchronous image-loading process.

In our example, we can define a Publisher in the ImageLoader that holds the current phase.

// ...

// 1
enum AsyncImagePhase {
    case empty
    case success(Image)
    case failure(Error)
}

private class ImageLoader: ObservableObject {
    private static let session: URLSession = {
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .returnCacheDataElseLoad
        let session = URLSession(configuration: configuration)
        return session
    }()

    // 2
    private enum LoaderError: Swift.Error {
        case missingURL
        case failedToDecodeFromData
    }
    
    // 3
    @Published var phase = AsyncImagePhase.empty

    private var subscriptions: [AnyCancellable] = []

    // ...

    func load() {
        let url: URL

        switch source {
        // 4
        case .local(let name):
            phase = .success(Image(name))
            return
        // 5
        case .remote(let theUrl):
            if let theUrl = theUrl {
                url = theUrl
            } else {
                phase = .failure(LoaderError.missingURL)
                return
            }
        // 6
        case .captured(let uiImage):
            phase = .success(Image(uiImage: uiImage))
            return
        }

        // 7
        ImageLoader.session.dataTaskPublisher(for: url)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    self.phase = .failure(error)
                }
            }, receiveValue: {
                if let image = UIImage(data: $0.data) {
                    self.phase = .success(Image(uiImage: image))
                } else {
                    self.phase = .failure(LoaderError.failedToDecodeFromData)
                }
            })
            .store(in: &subscriptions)
    }

    // ...
}

Here is a breakdown of what is happening with the code:

  1. Enum AsyncImagePhase defines a bunch of image loading states like empty, success, and failed.
  1. Define the potential loading errors.
  1. Define a Publisher of the loading image phase.
  1. For local images, simply create an Image view using the file name and pass it into the successful phase.
  1. For remote images, handle loading success and failure respectively.
  1. For captured images, simply create an Image view with the UIImage input parameter and pass it into the successful phase.
  1. Use the shared URLSession instance to load an image from the specified URL, and deal with loading errors accordingly.

Implement the AsyncImage view

Next, implement the AsyncImage view:

// 1
struct AsyncImage<Content>: View where Content: View {

    // 2
    @StateObject fileprivate var loader: ImageLoader

    // 3
    @ViewBuilder private var content: (AsyncImagePhase) -> Content

    // 4
    init(source: ImageSource, @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
        _loader = .init(wrappedValue: ImageLoader(source: source))
        self.content = content
    }

    // 5
    var body: some View {
        content(loader.phase).onAppear {
            loader.load()
        }
    }
}

What this code is doing:

  1. Defines an AsyncImage view that takes a generic type Content which itself must conform to the View protocol.
  1. 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.
  1. The content property is a closure that takes an AsyncImagePhase as input and returns a Content. The AsyncImagePhase represents the different states the image can be in, such as loading, success, or failure.
  1. The default initializer takes an ImageSource and the closure content as inputs, which lets us implement a closure that receives an AsyncImagePhase to indicate the state of the loading operation.
  1. In the body property, we start image loading when AsyncImage’s body appears.

Custom Initializer

By creating a custom AsyncImage view, you can customize its initializer to suit your specific needs. For example, you might want to add support for placeholder images that display while the image is still loading or the loading fails.

extension AsyncImage {

    // 1
    init<I, P>(
        source: ImageSource,
        @ViewBuilder content: @escaping (Image) -> I,
        @ViewBuilder placeholder: @escaping () -> P) where
        // 2
        Content == _ConditionalContent<I, P>,
        I : View,
        P : View {
        self.init(source: source) { phase in
            switch phase {
            case .success(let image):
                content(image)
            case .empty, .failure:
                placeholder()
            }
        }
    }
}

  1. This custom initializer for the AsyncImage view allows for the custom content and placeholder views to be provided.
  1. _ConditionalContent is how SwiftUI encodes view type information when dealing with ifif/else, and switch conditional branching statements. The type _ConditionalContent<I, P> captures the fact the view can be either an Image or a Placeholder.

There are certain things you need to be aware of regarding _ConditionalContent:

_ConditionalContent is a type defined in SwiftUI’s internal implementation, which is not meant to be accessed directly by developers. It is used by SwiftUI to conditionally render views based on some condition.

While it is technically possible to reference _ConditionalContent directly in your SwiftUI code, doing so is not recommended because it is an internal implementation detail that may change in future versions of SwiftUI. Relying on such internal implementation details can lead to unexpected behavior or crashes if the implementation changes.

Instead, you can refactor switch into a separate view using if statements or the @ViewBuilder attribute to achieve the same result without directly referencing the internal _ConditionalContent type. This approach is a safer and more future-proof way of conditionally rendering views in SwiftUI.

Here’s an example of how to conditionally render a view using an if statement:

struct DefaultAsyncImageContentView<Success: View, FailureOrPlaceholder: View>: View {
    var image: Image?
    @ViewBuilder var success: (Image) -> Success
    @ViewBuilder var failureOrPlaceholder: FailureOrPlaceholder

    init(image: Image? = nil, @ViewBuilder success: @escaping (Image) -> Success, @ViewBuilder failureOrPlaceholder: () -> FailureOrPlaceholder)     {
        self.image = image 
        self.success = success
        self.failureOrPlaceholder = failureOrPlaceholder()
    }

    var body: some View {
        if let image {
            success(image)
        } else {
            failureOrPlaceholder
        }
    }
}

extension AsyncImage {
    init<I, P>(
        source: ImageSource,
        @ViewBuilder content: @escaping (Image) -> I,
        @ViewBuilder placeholder: @escaping () -> P) where
        Content == DefaultAsyncImageContentView<I, P>, 
        I : View,
        P : View {
        self.init(source: source) { phase in
            var image: Image?
            if case let .success(loadedImage) = phase {
                image = loadedImage
            }
            return DefaultAsyncImageContentView(image: image, success: content, failureOrPlaceholder: placeholder)
        }
    }
}

As you can see in the examples above, the custom initializers allow us to take complete control of all the steps of image presentation.

Conclusion

In summary, creating a custom AsyncImage view can give you more control over the loading, processing, and display of images in your SwiftUI app, and can help you meet the specific requirements of your app. Thanks for reading. I hope you enjoyed the post.

The post Asynchronously load images with customized AsyncImage view in SwiftUI appeared first on Big Nerd Ranch.