Home APPS Asynchronously load images with customized AsyncImage view in SwiftUI

Asynchronously load images with customized AsyncImage view in SwiftUI

by NORTH CAROLINA DIGITAL NEWS


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Ā if,Ā if/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.





Source link

Related Posts