← Back to list

SwiftUI: Async Image and Beyond

Built-in to Custom! Caching, Compressed Image and Load Full Resolution on request!

Itsuki in Level Up Coding · 2025-08-09 06:05 · 62 claps · 6.2 min read
#swiftui #asyncimage #ios-app-development #ios-development
Open on Medium ↗
Wiki topics: 📱 · Mobile Development 📰 · Journalism & News

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.

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.
  • AsyncImages do 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