AsyncImage
is a convenient SwiftUI view that loads remote images using a URL. It’s especially useful in apps that interact with REST APIs since images are usually referenced using URLs in JSON data.
While Apple’s documentation might lead you to believe that AsyncImage
caches the downloaded images, that is not the case. If you need image caching, you must implement your own custom solution.
FREE GUIDE - SwiftUI App Architecture: Design Patterns and Best Practices
MVC, MVVM, and MV in SwiftUI, plus practical lessons on code encapsulation and data flow.
DOWNLOAD THE FREE GUIDETable of contents
- How to use AsyncImage to download an image from a URL
- Resizing the downloaded image and displaying a placeholder
- Showing a different view in case of a download error
- Displaying the images returned in JSON data by a REST API
- Does AsyncImage cache the downloaded images?
- Implementing custom image download and caching mechanisms
How to use AsyncImage to download an image from a URL
Using AsyncImage
in your code is straightforward. Its simplest initializer takes an optional URL.
struct PokemonView: View {
var body: some View {
let url = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png")
AsyncImage(url: url)
.frame(width: 200, height: 200)
}
}
#Preview {
PokemonView()
}
When initialized with only a URL, AsyncImage
displays a gray rectangle while loading the image and in case of error.
You can resize an AsyncImage
like any other view using the frame(width:height:alignment:)
modifier. However, that does not resize the downloaded image, which can exceed the view bounds.
Resizing the downloaded image and displaying a placeholder
Common Image
view modifiers like resizable(capInsets:resizingMode:)
do not work with AsyncImage
. To resize the downloaded image, use the init(url:scale:content:placeholder:)
initializer.
This initializer lets you specify a placeholder to display while the image loads. It is common to use a ProgressView
.
struct PokemonView: View {
var body: some View {
let url = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png")
AsyncImage(url: nil) { image in
image.resizable()
} placeholder: {
ProgressView()
.controlSize(.large)
}
.frame(width: 200, height: 200)
}
}
Adding the resizable(capInsets:resizingMode:)
view modifier to the image is enough to constrain it inside the frame applied to the wrapping AsyncImage
view.
Showing a different view in case of a download error
When AsyncImage
encounters an error during the download, it displays the placeholder by default. If you use a ProgressView
, it will spin indefinitely.
You must use the init(url:scale:transaction:content:)
initializer to display a different view when an image fails to download. Its content closure receives an AsyncImagePhase
parameter, which you can use to detect errors.
struct PokemonView: View {
var body: some View {
let url = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png")
AsyncImage(url: url) { phase in
if let image = phase.image {
image.resizable()
} else if phase.error != nil {
Image(systemName: "photo")
.font(.title)
.foregroundStyle(.secondary)
} else {
ProgressView()
.controlSize(.large)
}
}
.frame(width: 200, height: 200)
}
}
In theory, you can also provide a Button
to allow the user to try reloading the image.
struct PokemonView: View {
var body: some View {
let url = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwor/1.png")
AsyncImage(url: url) { phase in
if let image = phase.image {
image.resizable()
} else if phase.error != nil {
ContentUnavailableView(label: {
Image(systemName: "photo")
.font(.title)
.foregroundStyle(.secondary)
}, actions: {
Button(action: {}) {
Label("Retry", systemImage: "arrow.counterclockwise")
}
})
} else {
ProgressView()
.controlSize(.large)
}
}
.frame(width: 200, height: 200)
}
}
However, you can see one of AsyncImage
’s first limitations here. Since it does not provide any way to control the downloading of a remote image, triggering a retry in the button’s action is impossible.
When you need this level of sophistication, you should implement your own image downloading mechanism instead of relying on AsyncImage
. I will show you how to do so below.
Displaying the images returned in JSON data by a REST API
AsyncImage
is especially useful when performing REST API calls. Images inside JSON data are usually carried as URLs, often pointing to a content delivery network.
For our example, we will use the Pokéapi to display a list of Pokémon with their relative image.
For starters, we have to create model types for the data returned by the API. The list of Pokémon is retrieved through the Pokémon endpoint, which returns an array of entries with URLs pointing to the details of a Pokémon.
{
"results": [
{
"name": "bulbasaur",
"url": "https://pokeapi.co/api/v2/pokemon/1/"
}
]
}
We create two corresponding nested structures to parse this JSON data in Swift.
struct Response: Decodable {
let results: [Entry]
}
struct Entry: Decodable {
let url: URL
}
The details for each Pokémon are extensive, but we’re only interested in a small part of the returned information for our example.
{
"id": 1,
"name": "bulbasaur",
"sprites": {
"other": {
"official-artwork": {
"front_default":"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/1.png"
}
}
}
}
Since the image URL is deeply nested in the JSON data, we need to explicitly decode and flatten the nested JSON objects using a custom decoding initializer.
struct Pokemon: Decodable, Identifiable {
let id: Int
let name: String
let imageURL: URL
enum CodingKeys: CodingKey {
case id
case name
case sprites
}
enum SpritesCodingKeys: String, CodingKey {
case other
}
enum OtherCodingKeys: String, CodingKey {
case artwork = "official-artwork"
}
enum ArtworkCodingKeys: String, CodingKey {
case front = "front_default"
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
let sprites = try container.nestedContainer(keyedBy: SpritesCodingKeys.self, forKey: .sprites)
let other = try sprites.nestedContainer(keyedBy: OtherCodingKeys.self, forKey: .other)
let artwork = try other.nestedContainer(keyedBy: ArtworkCodingKeys.self, forKey: .artwork)
imageURL = try artwork.decode(URL.self, forKey: .front)
}
}
We can then create a row view for our list of Pokémon using AsyncImage
to display the image URLs returned by the API. We can preview the result by putting some sample JSON data inside a file in our Xcode project and reading it using the main Bundle
.
struct PokemonRow: View {
let pokemon: Pokemon
var body: some View {
LabeledContent {
AsyncImage(url: pokemon.imageURL) { phase in
if let image = phase.image {
image
.resizable()
.aspectRatio(contentMode: .fit)
} else if phase.error != nil {
Image(systemName: "photo")
.font(.title)
.foregroundStyle(.secondary)
} else {
ProgressView()
}
}
.frame(width: 100)
} label: {
Text(pokemon.name.capitalized)
.font(.title)
.bold()
}
}
}
#Preview {
List {
PokemonRow(pokemon: .preview)
}
}
extension Pokemon {
static let preview: Self = {
let url = Bundle.main.url(forResource: "Bulbasaur", withExtension: "json")!
let data = try! Data(contentsOf: url)
return try! JSONDecoder().decode(Pokemon.self, from: data)
}()
}
Fetching the data using Swift structured concurrency in a view model
The data is retrieved from the API and decoded inside a view model.
We need to fetch the list of Pokémon and then fetch the details for each element of the list separately. To make all these calls in parallel, we need to use Swift structured concurrency inside a task group.
@Observable class ViewModel {
var pokemon: [Pokemon] = []
private let url = URL(string: "https://pokeapi.co/api/v2/pokemon/")!
func fetchPokemonList() async throws {
pokemon = try await withThrowingTaskGroup(of: Pokemon.self) { group in
let (data, _) = try await URLSession.shared.data(from: url)
let response = try JSONDecoder().decode(Response.self, from: data)
for result in response.results {
group.addTask {
let (data, _) = try await URLSession.shared.data(from: result.url)
return try JSONDecoder().decode(Pokemon.self, from: data)
}
}
var results: [Pokemon] = []
for try await pokemon in group {
results.append(pokemon)
}
return results.sorted(using: KeyPathComparator(\.name))
}
}
}
Finally, we can perform the network requests when the content view of our sample app appears and display the results inside a List
using the PokemonRow
view we previously created.
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
List(viewModel.pokemon) { pokemon in
PokemonRow(pokemon: pokemon)
}
.task { await refresh() }
.refreshable { await refresh() }
}
func refresh() async {
try? await viewModel.fetchPokemonList()
}
}
#Preview {
ContentView()
}
After fetching the JSON data from the Pokémon API, each AsyncImage
will automatically fetch their images independently as soon as the corresponding list row appears on the screen.
Does AsyncImage cache the downloaded images?
AsyncImage
does not cache the downloaded images. However, Apple’s documentation is not clear.
The documentation for AsyncImage
states that it uses the shared URLSession instance, which in turn uses the shared URLCache instance. There, we can find this sentence:
_If your app doesn’t have special caching requirements or constraints, the default shared cache instance should be acceptable._
The Accessing cached data article says that .useProtocolCachePolicy
is the default value for all requests, and its documentation explains how this policy works for HTTP.
That might lead you to believe that images loaded by AsyncImage
are cached by the shared URLSession
instance. However, empirical evidence shows the opposite.
We can force a screen update inside the refreshable(action:)
view modifier, making all AsyncImage
instances reload their content whenever we refresh the list’s content.
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
List(viewModel.pokemon) { pokemon in
PokemonRow(pokemon: pokemon)
}
.task { await refresh() }
.refreshable {
viewModel.pokemon = []
await refresh()
}
}
func refresh() async {
try? await viewModel.fetchPokemonList()
}
}
Xcode’s Network Report shows a spike in network traffic every time we pull to refresh, which suggests that images are not cached.
When running the app on an iOS device, you can click the Profile in Instruments button to analyze its HTTP traffic.
The report shows that REST API calls are cached, but image requests are not. You can click on an image request and see that its response carries a Cache-Control
header set to max-age=300
, which implies that the response should be cached.
The conclusion is that AsyncImage
does not cache images. If I have to speculate, I think Apple engineers used URLRequest
instances with a custom cache policy instead of the default one.
Implementing custom image download and caching mechanisms
Luckily, it is not complicated to cache downloaded images. We only need to create a URLSession
with a .default
configuration instead of using the shared one.
Unfortunately, replacing the session used by AsyncImage
is impossible, so we also need to implement a custom image download solution.
If you do not mind adding dependencies to your project, you can use third-party libraries like SwiftUI CachedAsyncImage, Nuke, or Kingfisher. Some libraries allow you to easily cache images by replacing any AsyncImage
instance with one of their dedicated views.
However, that’s overkill. In my opinion, it is a mistake to think you must replicate the behavior of AsyncImage
when creating a custom solution.
AsyncImage
is a convenience provided by the SwiftUI framework. If your app requires a sophisticated caching solution, it should not be placed inside views.
It is better to place caching in a centralized location where you can fine-tune it to fit your app requirements. In our example app, that is inside the ViewModel
class, where we already perform other network requests.
Storing the downloaded image data inside model types
We also need to decide how to store the downloaded image data. One solution would be to store the Data
inside our Pokemon
structure and display it using an Image
view instead of AsyncImage
.
However, there is a more straightforward solution that allows us to keep our view code unaltered and use AsyncImage
to display different views while loading and in case of error.
For that to work, we must store the downloaded data as a data URL instead of using the Data
type.
struct Pokemon: Decodable, Identifiable {
let id: Int
let name: String
let imageURL: URL
var imageDataURL: URL?
// ...
}
@Observable class ViewModel {
var pokemon: [Pokemon] = []
private let url = URL(string: "https://pokeapi.co/api/v2/pokemon/")!
private let session = URLSession(configuration: .default)
func fetchPokemonList() async throws {
// ...
}
func downloadImage(for pokemon: Pokemon) async throws {
guard let index = self.pokemon.firstIndex(where: { $0.id == pokemon.id }),
self.pokemon[index].imageDataURL == nil
else { return }
let (data, _) = try await session.data(from: pokemon.imageURL)
let dataURL = URL(string: "data:image/png;base64," + data.base64EncodedString())
self.pokemon[index].imageDataURL = dataURL
}
}
struct PokemonRow: View {
let pokemon: Pokemon
var body: some View {
LabeledContent {
AsyncImage(url: pokemon.imageDataURL) { phase in
// ...
}
.frame(width: 100)
} label: {
// ...
}
}
}
Finally, we trigger the image loading by adding the task(priority:_:)
view modifier to the PokemonRow
inside the List
body.
struct ContentView: View {
@State private var viewModel = ViewModel()
var body: some View {
List(viewModel.pokemon) { pokemon in
PokemonRow(pokemon: pokemon)
.task { try? await viewModel.downloadImage(for: pokemon) }
}
.task { await refresh() }
.refreshable {
viewModel.pokemon = []
await refresh()
}
}
func refresh() async {
try? await viewModel.fetchPokemonList()
}
}
Using a URLSession
instance with a .default
configuration is enough to cache images on disk even across app launches.
Customizing the image caching for offline use
Using a custom URLSession
instance allows you to customize your app’s caching behavior. For example, you can use the .returnCacheDataDontLoad cache policy when there is no internet connection to provide an offline mode for your app.
If the caching behavior of URLSession
and URLCache
does not meet your requirements, you can develop your own custom caching mechanism.
Keep also in mind that while this solution works with minimal code, it seems to have a higher failure rate than AsyncImage
in my experiments. This might be because AsyncImage
retries to download an image before returning an error.
SwiftUI App Architecture: Design Patterns and Best Practices
It's easy to make an app by throwing some code together. But without best practices and robust architecture, you soon end up with unmanageable spaghetti code. In this guide I'll show you how to properly structure SwiftUI apps.
Matteo has been developing apps for iOS since 2008. He has been teaching iOS development best practices to hundreds of students since 2015 and he is the developer of Vulcan, a macOS app to generate SwiftUI code. Before that he was a freelance iOS developer for small and big clients, including TomTom, Squla, Siilo, and Layar. Matteo got a master’s degree in computer science and computational logic at the University of Turin. In his spare time he dances and teaches tango.