AsyncImage in SwiftUI: Loading Images from URLs [with Caching]

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.

Architecting SwiftUI apps with MVC and MVVM

GET THE FREE BOOK NOW

Table of contents

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.

Architecting SwiftUI apps with MVC and MVVM

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.

GET THE FREE BOOK NOW

Leave a Comment