SwiftUI: Async Image and Beyond
Built-in to Custom! Caching, Compressed Image and Load Full Resolution on request!
SwiftUI: AsyncImage and Beyond

[AsyncImage](https://developer.apple.com/documentation/swiftui/asyncimage)
A view that asynchronously loads and displays an image.
Simple! Yet Powerful!
This view uses the shared [URLSession](https://developer.apple.com/documentation/Foundation/URLSession) instance to load an image from a URL and then display it. We don’t have to make data requests nor handling data to image conversion by ourselves!
Of course, there are constraints!
The most significant one is memory consumption, especially you have an entire list of those high-resolution images when you only maybe need some thumbnails!
In this article, let’s first check out the basics of [AsyncImage](https://developer.apple.com/documentation/swiftui/asyncimage), providing a custom placeholder, handle errors, add animations and etc.
We will then be building our own AsyncImage view that allows us to cache the image data, load a compressed image initially and only show full resolution on request.
Built-in AsyncImage
To use the [AsyncImage](https://developer.apple.com/documentation/swiftui/asyncimage) view, it is as simple as passing in the image URL!
AsyncImage(url: URL(string: "https://www.kindpng.com/picc/m/290-2906336_sleepy-pikachu-hd-png-download.png"))

See how much less code we have in comparison to making a URL request ourselves? Amazing!
Custom PlaceHolder View
Until the image loads, the view displays a standard placeholder (something like Color.gray?) that fills all the available space. (We can constraint this as we will see in couple seconds! Leave that out for now.)
But we can also provide a custom placeholder view, for example, a [ProgressView](https://developer.apple.com/documentation/swiftui/progressview) by using the [init(url:scale:content:placeholder:)](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:content:placeholder:)) initializer.
AsyncImage(url: url) { image in
image
} placeholder: {
ProgressView()
}
Monitor Phase and Error Handling
As we all know, url requests don’t always success, nor does the conversion from data to image!
To gain more control over the loading process and handle errors, we can use the [init(url:scale:transaction:content:)](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:transaction:content:)) initializer, which takes a content closure that receives an [AsyncImagePhase](https://developer.apple.com/documentation/swiftui/asyncimagephase) to indicate the state of the loading operation.
There are three different phases.
[empty](https://developer.apple.com/documentation/swiftui/asyncimagephase/empty) if no image is loaded. This can be either anilURL, or a request is currently being made and we are waiting for the response.[success(Image)](https://developer.apple.com/documentation/swiftui/asyncimagephase/success(_:)): we get ourImage): something went wrong! Oops!
Here is how we can provide views based on the [AsyncImagePhase](https://developer.apple.com/documentation/swiftui/asyncimagephase) we receive from the content closure.
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
case .failure(let error):
Text("\(error.localizedDescription)")
@unknown default:
Color.red
}
}
Add Animation
We can use the transaction parameter in the [init(url:scale:transaction:content:)](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:transaction:content:)) initializer to provide a custom animation to use when the phase changes.
AsyncImage(url: url, transaction: .init(animation: .easeIn)) { phase in
//...
}
To add the same transitions when we change the source URL, we will need to assign an id to the [AsyncImage](https://developer.apple.com/documentation/swiftui/asyncimage).
struct AsyncImageDemo: View {
@State private var url = URL(string: "https://www.kindpng.com/picc/m/290-2906336_sleepy-pikachu-hd-png-download.png")
var body: some View {
AsyncImage(url: url, transaction: .init(animation: .easeIn)) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
case .failure(let error):
Text("\(error.localizedDescription)")
@unknown default:
Color.red
}
}
.id(url?.absoluteString ?? UUID().uuidString)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: {
self.url = URL(string: "https://i.etsystatic.com/48633458/r/il/07a697/5673581891/il_570xN.5673581891_5xcf.jpg")
})
}
}
}

You might be wondering what if we don’t apply the id and here is what will happen.

Image Scale
The scale to use for the image is default to 1. We can pass in the scale parameter to the [init(url:scale:transaction:content:)](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:transaction:content:)) initializer to set it to a different value.
AsyncImage(
url: url,
scale: 2,
transaction: .init(animation: .easeIn)
) { phase in
//...
}

Modifiers
As we can see above, this AsyncImage view will try to take all possible spaces. You might want to apply the frame modifier to constraint the behavior but the fact is that this modifier only affects the placeholder view but not the actual image loaded.
AsyncImage(url: url)
.frame(width: 200, height: 80)

We cannot apply image-specific modifiers, like [resizable(capInsets:resizingMode:)](https://developer.apple.com/documentation/swiftui/image/resizable(capinsets:resizingmode:)), directly to an AsyncImage.
But!
Since we have access to the [Image](https://developer.apple.com/documentation/swiftui/image) instance in the content closure, we can simply apply those directly to the Image instead!
AsyncImage(
url: url,
transaction: .init(animation: .easeIn)
) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
case .failure(let error):
Text("\(error.localizedDescription)")
@unknown default:
Color.red
}
}
.frame(width: 400)
Of course, all other regular view modifiers will work as well, cornerRadius, clipShape, and etc.
For example, if we are using the AsyncImage view for a profile icon.
AsyncImage(
url: url,
transaction: .init(animation: .easeIn)
) { phase in
//. ...
}
.clipShape(Circle())

Custom AsyncImage
As you might know many of the memory leaks (and eventually crashes) within iOS app comes from huge images!
Imagine we have a full List of AsyncImages!
- We probably don’t need the full-size image starting from the beginning.
AsyncImagesdo NOT cache so network requests happens on view updates.
So!
Let’s make our Custom one!
- cache the image data
- load a compressed image initially
- only show full resolution on request (like those images on twitter where we long press to load 4k)!
The idea is simple! We all know how to make data requests!
private struct _AsyncImage<Content: View>: View {
enum LoadImageError: Error {
case timeout
case badRequest
case loadImageFailed
}
var url: URL?
var transaction: Transaction = Transaction()
var compressedSize: CGSize = .init(width: 10, height: 8)
var loadFullResolution: Bool = false
@ViewBuilder var content: (AsyncImagePhase) -> Content
@State private var uiImage: UIImage? = nil
@State private var phase: AsyncImagePhase = .empty
var body: some View {
content(phase)
.transaction { view in
view.animation = self.transaction.animation
}
.onChange(of: url, initial: true) {
Task {
await self.updateImage()
}
}
.onChange(of: loadFullResolution, {
Task {
await self.updateImage()
}
})
}
private func updateImage() async {
let targetSize = loadFullResolution ? nil : compressedSize
guard let url else {
self.phase = .empty
return
}
let request = URLRequest(url: url)
if let cached = URLCache.shared.cachedResponse(for: request) {
let image = cached.data.compressedImage(to: targetSize)
if let image = image {
self.phase = .success(Image(uiImage: image))
return
} else {
URLCache.shared.removeCachedResponse(for: request)
}
}
var data: Data!
var response: URLResponse!
do {
(data, response) = try await URLSession.shared.data(from: url)
} catch {
self.phase = .failure(LoadImageError.timeout)
return
}
guard let response = response as? HTTPURLResponse, (200...300 ~= response.statusCode) else {
self.phase = .failure(LoadImageError.badRequest)
return
}
URLCache.shared.storeCachedResponse(.init(response: response, data: data), for: request)
guard let image = data.compressedImage(to: targetSize) else {
self.phase = .failure(LoadImageError.loadImageFailed)
return
}
self.phase = .success(Image(uiImage: image))
return
}
}
private extension CGSize {
var pngBytes: Int {
Int(width * height * 9)
}
var jpegBytes: Int {
Int(width * height * 3)
}
}
private extension Data {
func compressedImage(to size: CGSize?) -> UIImage? {
guard let size = size else {
return UIImage(data: self)
}
if self.count < size.pngBytes {
return UIImage(data: self)
}
let scale = UIScreen.main.scale
let options: [CFString: Any] = [
kCGImageSourceShouldCache : false,
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: Swift.max(size.width, size.height) * scale
]
guard
let src = CGImageSourceCreateWithData(self as CFData, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(src, 0, options as CFDictionary)
else { return nil }
return UIImage(cgImage: cgImage)
}
}
The jpegBytes and pngBytes are (of course) approximate, but should be enough for our purpose.
I have also tried to keep the syntax as similar to the built-in [AsyncImage](https://developer.apple.com/documentation/swiftui/asyncimage) as possible so we can then use it by simply replacing the AsyncImage with _AsyncImage!
_AsyncImage(
url: url,
transaction: .init(animation: .easeIn),
) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
case .failure(let error):
Text("\(error.localizedDescription)")
@unknown default:
Color.red
}
}
If we want to enable the loading for a full resolution one on request, all we have todo is to add a state variable and some buttons controlling it!
struct AsyncImageDemo: View {
@State private var url = URL(string: "https://images.wallpapersden.com/image/download/eve-online-gaming-4k_bG1nbGeUmZqaraWkpJRobWllrWdma2U.jpg")
@State private var fullResolution: Bool = false
var body: some View {
_AsyncImage(
url: url,
transaction: .init(animation: .easeIn),
loadFullResolution: self.fullResolution
) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFit()
case .failure(let error):
Text("\(error.localizedDescription)")
@unknown default:
Color.red
}
}
.contextMenu {
if !self.fullResolution {
Button(action: {
self.fullResolution = true
}, label: {
Text("Load Full Resolution")
})
}
}
}
}

That’s it for this article!
Thank you for reading!
Happy caching and compressing images!
메타데이터
- post_id
- 0e867e2b2be3
- slug
- swiftui-async-image-and-beyond-0e867e2b2be3
- url
- https://levelup.gitconnected.com/swiftui-async-image-and-beyond-0e867e2b2be3
- canonical_url
- https://levelup.gitconnected.com/swiftui-async-image-and-beyond-0e867e2b2be3
- author_url
- https://medium.com/@itsuki.enjoy
- status
- ok
- fetched_at
- 2026-08-21 14:15:36