At the time of writing this post, WWDC21 is still in progress. And as it seems, this year's announcements reveal really great and exciting news for developers. Among them, SwiftUI is equipped with new APIs, views and tools that will undoubtedly make building apps an even more easy and fun task.
A new, interesting view introduced in this, third release of SwiftUI, is the AsyncImage. This view displays images after having fetched them from a remote URL. Doing so has been traditionally a manual task, but now AsyncImage does all the work behind the scenes, until the image is presented in the view. The AsyncImage API is simple, yet flexible enough; it provides options to display a placeholder image while waiting for the remote one, deal with potential errors, show the downloaded image animated, and of course, style the image as we like using view modifiers.
There is only one downside; fetched images are not cached for future use. AsyncImage view downloads the remote image whenever it's about to be displayed. That's okay for images that will be shown to users just a few times. But for remote images that an app presents often, it's not recommended to always fetch them in real time. A manual implementation to fetch and cache images is still necessary.
Hands-on to the AsyncImage
Time to play around a bit with the AsyncImage and see what it does. The simplest way to use it is like so:
AsyncImage(url: url)
The url argument is a URL object pointing to a remote image:
let url = URL(string: "https://images.pexels.com/photos/8016369/pexels-photo-8016369.jpeg")
If you add the above to a SwiftUI view and run it in a simulator (or in a live preview), you'll see the image being displayed after a few moments. However, that's not so much user friendly; a default gray colored box is shown temporarily as a placeholder. We need to make clear to users that an asynchronous operation is running, probably with a progress view and some message, or display a more proper placeholder image until the remote one has been fetched.
Let's focus on using a placeholder image first. In order to achieve that, there's another initializer to use: init(url:scale:content:placeholder:).
The first argument is the URL of the remote image, and the second is the scale to use for the image. The default scale value is 1. The other two arguments are closures, and we'll talk about them in just a while:
AsyncImage(url: url) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding()
} placeholder: {
placeholderImage()
}
I'm not providing a value for the image scale here; I skip it, so the default one is used instead.
Let's talk about the two closures now. The argument of the first closure is a SwiftUI Image view containing the image after it has been downloaded. We add it to the closure's body, and as you see, we can use any view modifier that will style the image appropriately.
The second closure is the place to add an Image view with a placeholder image. This one is going to be displayed for as long as we wait for the remote one to be fetched. It will keep being displayed in case the actual image cannot be loaded for some reason. In this example I'm calling the placeholderImage() method, which is the next one:
@ViewBuilder
func placeholderImage() -> some View {
Image(systemName: "photo")
.renderingMode(.template)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 150, height: 150)
.foregroundColor(.gray)
}
The placeholderImage() method returns a SwiftUI view, therefore it's marked with the @ViewBuilder attribute. As a placeholder image I'm using an SF Symbol.

Credits: Photo by Lucy from Pexels.
AsyncImage and the AsyncImagePhase
AsyncImage has another initializer that allows to get the image load results in phases: init(url:scale:transaction:content:).
See the following example:
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding()
case .failure(let error):
Text(error.localizedDescription)
case .empty:
waitView()
@unknown default:
EmptyView()
}
}
I'm supplying here only the URL and the content argument, which is a closure. The closure's argument is an AsyncImagePhase value, where AsyncImagePhase is an enum with cases that represent certain load phases.
In fact, you can see all available cases of the AsyncImagePhase in the switch statement right above. The first one is the case where the image has been successfully fetched. The associated value of the case is a SwiftUI Image view, which we can decorate using any modifiers necessary.
The second case contains the error, if any occurrs. In this example I'm just presenting the error description in a Text view.
The last case called .empty represents the state of the AsyncImage view when there is not a loaded image yet. Here, we can add a placeholder view, a progress view, or anything else that fits to the app. In the above example I'm calling the waitView() method:
@ViewBuilder
func waitView() -> some View {
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .indigo))
Text("Fetching image...")
}
}
Its purpose is to present a progress view along with a text.
See that there is also an @unknown default case in the switch statement; that's necessary for handling any new values that might be added to the AsyncImagePhase enum in the future. An EmptyView is the best candidate to handle such a case.
The above AsyncImage implementation results to this:

In general, the above initializer provides greater control over the image fetching results. We can detect when an error has occurred, and therefore display a different view; something that we can't do using the placeholder initializer met in the previous part.
But that's not the only benefit of this initializer. With the transaction argument we can pass the animation to put in motion when the phase changes. See the same example as before, but with an animation provided as argument this time:
AsyncImage(url: url,
transaction: Transaction(animation: .easeInOut(duration: 2.5))
) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding()
case .failure(let error):
Text(error.localizedDescription)
case .empty:
waitView()
@unknown default:
EmptyView()
}
}

Summary
This is pretty much everything about the AsyncImage view in order to fetch and display remote images. There are two things I didn't mention in the introduction; the first one is that AsyncImage uses the shared URLSession instance to fetch an image from the given URL. The second is that AsyncImage is available in iOS 15 and above. Nevertheless, it's a great new view that will be useful beyond any doubt, and an amazing new addition to the SwiftUI arsenal. Thank you for reading!