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:
- 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.
- 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:
- Enum
AsyncImagePhase
defines a bunch of image loading states like empty, success, and failed.
- Define the potential loading errors.
- Define a
Publisher
of the loading image phase.
- For local images, simply create an Image view using the file name and pass it into the successful phase.
- For remote images, handle loading success and failure respectively.
- For captured images, simply create an Image view with the UIImage input parameter and pass it into the successful phase.
- 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:
- Defines an
AsyncImage
view that takes a generic typeContent
which itself must conform to theView
protocol.
- 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.
- 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.
- The default initializer takes an
ImageSource
and the closurecontent
as inputs, which lets us implement a closure that receives anAsyncImagePhase
to indicate the state of the loading operation.
- 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() } } } }
- This custom initializer for the AsyncImage view allows for the custom content and placeholder views to be provided.
_ConditionalContent
is how SwiftUI encodes view type information when dealing withif
,if/else
, andswitch
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.