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
ImageLoaderto 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 typeĀContentĀ which itself must conform to theĀViewĀ 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 closureĀcontentĀ as inputs, which lets us implement a closure that receives anĀAsyncImagePhaseĀ 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 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.
