76

I'm new to SwiftUI and was looking how to download images from a URL. I've found out that in iOS15 you can use AsyncImage to handle all the phases of an Image. The code looks like this.

    AsyncImage(url: URL(string: urlString)) { phase in
        switch phase {
        case .success(let image):
            image
                .someModifers
        case .empty:
            Image(systemName: "Placeholder Image")
                .someModifers
        case .failure(_):
            Image(systemName: "Error Image")
                .someModifers
        @unknown default:
            Image(systemName: "Placeholder Image")
                .someModifers
        }
    }

I would launch my app and every time I would scroll up & down on my List, it would download the images again. So how would I be able to add a cache. I was trying to add a cache the way I did in Swift. Something like this.

struct DummyStruct {
  var imageCache = NSCache<NSString, UIImage>()
  func downloadImageFromURLString(_ urlString: String) {
    guard let url = URL(string: urlString) else { return }
    URLSession.shared.dataTask(with: url) { data, response, error in
        if let _ = error {
            fatalError()
        }
        
        guard let data = data, let image = UIImage(data: data) else { return }
        imageCache.setObject(image, forKey: NSString(string: urlString))
    }
    .resume()
  }
}

But it didn't go to good. So I was wondering is there a way to add caching to AsyncImage? Thanks would appreciate any help.

3
  • check out this article about making really simple AsyncImage for iOS 14 which supports caching. Commented Sep 16, 2021 at 21:11
  • I am afraid that you can't, but check this component it has what you need: github.com/kean/NukeUI Commented Sep 16, 2021 at 21:12
  • Why not use Kingfisher or SDWebImageSwiftUI Commented Sep 21, 2023 at 9:10

9 Answers 9

78

I had the same problem as you. I solved it by writing a CachedAsyncImage that kept the same API as AsyncImage, so that they could be interchanged easily, also in view of future native cache support in AsyncImage.

I made a Swift Package to share it.

CachedAsyncImage has the exact same API and behavior as AsyncImage, so you just have to change this:

AsyncImage(url: logoURL)

to this:

CachedAsyncImage(url: logoURL)

In addition to AsyncImage initializers, you have the possibilities to specify the cache you want to use (by default URLCache.shared is used):

CachedAsyncImage(url: logoURL, urlCache: .imageCache)
// URLCache+imageCache.swift

extension URLCache {
    
    static let imageCache = URLCache(memoryCapacity: 512*1000*1000, diskCapacity: 10*1000*1000*1000)
}

Remember when setting the cache the response (in this case our image) must be no larger than about 5% of the disk cache (See this discussion).

Here is the repo.

Sign up to request clarification or add additional context in comments.

8 Comments

Perfect solution
Thanks! What happens if e.g. a table view requests the same image say three times, all at the same time? Will your solution cause three downloads of the same image (since no download to cache has completed yet)? Most solutions don't address this and it frequently happens
This library does not work. I'm testing it out and it fetches a remote image everytime when using a List
@Dr. Mr. Uncle yes the library doesn't work
It sure works. But you most likely have to increase your URLCache sizes for memory and disk space. Another factor would be the server-side cache-control headers of your image that you trying to load @Dr.Mr.Uncle
|
17

Hope this can help others. I found this great video which talks about using the code below to build a async image cache function for your own use.

import SwiftUI

struct CacheAsyncImage<Content>: View where Content: View{
    
    private let url: URL
    private let scale: CGFloat
    private let transaction: Transaction
    private let content: (AsyncImagePhase) -> Content
    
    init(
        url: URL,
        scale: CGFloat = 1.0,
        transaction: Transaction = Transaction(),
        @ViewBuilder content: @escaping (AsyncImagePhase) -> Content
    ){
        self.url = url
        self.scale = scale
        self.transaction = transaction
        self.content = content
    }
    
    var body: some View{
        if let cached = ImageCache[url]{
            let _ = print("cached: \(url.absoluteString)")
            content(.success(cached))
        }else{
            let _ = print("request: \(url.absoluteString)")
            AsyncImage(
                url: url,
                scale: scale,
                transaction: transaction
            ){phase in
                cacheAndRender(phase: phase)
            }
        }
    }
    func cacheAndRender(phase: AsyncImagePhase) -> some View{
        if case .success (let image) = phase {
            ImageCache[url] = image
        }
        return content(phase)
    }
}
fileprivate class ImageCache{
    static private var cache: [URL: Image] = [:]
    static subscript(url: URL) -> Image?{
        get{
            ImageCache.cache[url]
        }
        set{
            ImageCache.cache[url] = newValue
        }
    }
}

2 Comments

This will cause memory leaks. static private var cache: [URL: Image], try to test with 100 images
Transactions (animations) doesn't work. I modify: func cacheAndRender(phase: AsyncImagePhase) -> some View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { if let url = url, case .success (let image) = phase { ImageCache[url] = image } } return content(phase) }
15

Maybe later to the party, but I came up to this exact problem regarding poor performances of AsyncImage when used in conjunction with ScrollView / LazyVStack layouts.

According to this thread, seams that the problem is in someway due to Apple's current implementation and sometime in the future it will be solved.

I think that the most future-proof approach we can use is something similar to the response from Ryan Fung but, unfortunately, it uses an old syntax and miss the overloaded init (with and without placeholder).

I extended the solution, covering the missing cases on this GitHub's Gist. You can use it like current AsyncImage implementation, so that when it will support cache consistently you can swap it out.

3 Comments

Thanks Valvoline. Yes, I have resorted to using my own solution utilizing Combine and directly working with the cachedirectory.
Tried all answers, this one is the first working straight out of the box. Thanks! iOS 16.4, Xcode 14.3
If we quickly scroll it, it won't fetch image. iOS 15
14

I implemented the AsyncCachedImage for cached AsyncImage.

The usage is also the same as the original AsyncImage. You simply can try adding the following implementation and use it wherever you need.

Just copy-paste the following code:

@MainActor
struct AsyncCachedImage<ImageView: View, PlaceholderView: View>: View {
    // Input dependencies
    var url: URL?
    @ViewBuilder var content: (Image) -> ImageView
    @ViewBuilder var placeholder: () -> PlaceholderView
    
    // Downloaded image
    @State var image: UIImage? = nil
    
    init(
        url: URL?,
        @ViewBuilder content: @escaping (Image) -> ImageView,
        @ViewBuilder placeholder: @escaping () -> PlaceholderView
    ) {
        self.url = url
        self.content = content
        self.placeholder = placeholder
    }
    
    var body: some View {
        VStack {
            if let uiImage = image {
                content(Image(uiImage: uiImage))
            } else {
                placeholder()
                    .onAppear {
                        Task {
                            image = await downloadPhoto()
                        }
                    }
            }
        }
    }
    
    // Downloads if the image is not cached already
    // Otherwise returns from the cache
    private func downloadPhoto() async -> UIImage? {
        do {
            guard let url else { return nil }
            
            // Check if the image is cached already
            if let cachedResponse = URLCache.shared.cachedResponse(for: .init(url: url)) {
                return UIImage(data: cachedResponse.data)
            } else {
                let (data, response) = try await URLSession.shared.data(from: url)
                
                // Save returned image data into the cache
                URLCache.shared.storeCachedResponse(.init(response: response, data: data), for: .init(url: url))
                
                guard let image = UIImage(data: data) else {
                    return nil
                }
                
                return image
            }
        } catch {
            print("Error downloading: \(error)")
            return nil
        }
    }
}

Usage

Use whenever you need:

        AsyncCachedImage(url: photoURL) { image in
            image
                .resizable()
                .aspectRatio(contentMode: .fill)
        } placeholder: {
            ProgressView()
        }

For the full code example please check my repo https://github.com/MahiAlJawad/CachedAsyncImage-SwiftUI

Comments

2

AsyncImage uses default URLCache under the hood. The simplest way to manage the cache is to change the properties of the default URLCache

URLCache.shared.memoryCapacity = 50_000_000 // ~50 MB memory space
URLCache.shared.diskCapacity = 1_000_000_000 // ~1GB disk cache space

7 Comments

Do you have a source that confirms this? All the other answers imply there's no caching and you should add it yourself.
@stromyc actually the developer documentation clearly said that AsyncImage uses shared URLSession. Further I have tested this and this works.
Docs state: "This view uses the shared URLSession instance to load an image from the specified URL, and then display it." but when looking at my app's cache folder I don't see the cached images but I do see other cached responses so I'm skeptical of that statement.
I tested with Proxyman and there is no cache, all requests are sent repeatedly even with large caches defined! There are many people claiming there is a cache – but thats just 💩
Here is an article about AsyncImage's cache. And yes it seems to only take the caching headers into account: avanderlee.com/swiftui/downloading-caching-images So if the webserver is not configured sending these with the response, no caching will happen natively.
|
1

enter image description here



User like this

ImageView(url: URL(string: "https://wallpaperset.com/w/full/d/2/b/115638.jpg"))
        .frame(width: 300, height: 300)
        .cornerRadius(20)

ImageView(url: URL(string: "https://ba")) {

            // Placeholder
            Text("⚠️")
                .font(.system(size: 120))
        }
        .frame(width: 300, height: 300)
        .cornerRadius(20)


ImageView.swift

import SwiftUI

struct ImageView<Placeholder>: View where Placeholder: View {

    // MARK: - Value
    // MARK: Private
    @State private var image: Image? = nil
    @State private var task: Task<(), Never>? = nil
    @State private var isProgressing = false

    private let url: URL?
    private let placeholder: () -> Placeholder?


    // MARK: - Initializer
    init(url: URL?, @ViewBuilder placeholder: @escaping () -> Placeholder) {
        self.url = url
        self.placeholder = placeholder
    }

    init(url: URL?) where Placeholder == Color {
        self.init(url: url, placeholder: { Color("neutral9") })
    }
    
    
    // MARK: - View
    // MARK: Public
    var body: some View {
        GeometryReader { proxy in
            ZStack {
                placholderView
                imageView
                progressView
            }
            .frame(width: proxy.size.width, height: proxy.size.height)
            .task {
                task?.cancel()
                task = Task.detached(priority: .background) {
                    await MainActor.run { isProgressing = true }
                
                    do {
                        let image = try await ImageManager.shared.download(url: url)
                    
                        await MainActor.run {
                            isProgressing = false
                            self.image = image
                        }
                    
                    } catch {
                        await MainActor.run { isProgressing = false }
                    }
                }
            }
            .onDisappear {
                task?.cancel()
            }
        }
    }
    
    // MARK: Private
    @ViewBuilder
    private var imageView: some View {
        if let image = image {
            image
                .resizable()
                .scaledToFill()
        }
    }

    @ViewBuilder
    private var placholderView: some View {
        if !isProgressing, image == nil {
            placeholder()
        }
    }
    
    @ViewBuilder
    private var progressView: some View {
        if isProgressing {
            ProgressView()
                .progressViewStyle(.circular)
        }
    }
}


#if DEBUG
struct ImageView_Previews: PreviewProvider {

    static var previews: some View {
        let view = VStack {
            ImageView(url: URL(string: "https://wallpaperset.com/w/full/d/2/b/115638.jpg"))
                .frame(width: 300, height: 300)
                .cornerRadius(20)
        
            ImageView(url: URL(string: "https://wallpaperset.com/w/full/d/2/b/115638")) {
                Text("⚠️")
                    .font(.system(size: 120))
            }
            .frame(width: 300, height: 300)
            .cornerRadius(20)
        }
    
        view
            .previewDevice("iPhone 11 Pro")
            .preferredColorScheme(.light)
    }
}
#endif


ImageManager.swift

import SwiftUI
import Combine
import Photos

final class ImageManager {
    
    // MARK: - Singleton
    static let shared = ImageManager()
    
    
    // MARK: - Value
    // MARK: Private
    private lazy var imageCache = NSCache<NSString, UIImage>()
    private var loadTasks = [PHAsset: PHImageRequestID]()
    
    private let queue = DispatchQueue(label: "ImageDataManagerQueue")
    
    private lazy var imageManager: PHCachingImageManager = {
        let imageManager = PHCachingImageManager()
        imageManager.allowsCachingHighQualityImages = true
        return imageManager
    }()

    private lazy var downloadSession: URLSession = {
        let configuration = URLSessionConfiguration.default
        configuration.httpMaximumConnectionsPerHost = 90
        configuration.timeoutIntervalForRequest     = 90
        configuration.timeoutIntervalForResource    = 90
        return URLSession(configuration: configuration)
    }()
    
    
    // MARK: - Initializer
    private init() {}
    
    
    // MARK: - Function
    // MARK: Public
    func download(url: URL?) async throws -> Image {
        guard let url = url else { throw URLError(.badURL) }
        
        if let cachedImage = imageCache.object(forKey: url.absoluteString as NSString) {
            return Image(uiImage: cachedImage)
        }
    
        let data = (try await downloadSession.data(from: url)).0
        
        guard let image = UIImage(data: data) else { throw URLError(.badServerResponse) }
            queue.async { self.imageCache.setObject(image, forKey: url.absoluteString as NSString) }
    
        return Image(uiImage: image)
    }
}

Comments

1
import SwiftUI

struct InternetImage<Content: View>: View {

    var url: String
    
    @State private var image: UIImage?
    @State private var errors: String?

    @ViewBuilder var content: (Image) -> Content
    
    init(url: String, @ViewBuilder content: @escaping (Image) -> Content) {
        self.url = url
        self.content = content
    }
    

    var body: some View {
        VStack {
            if let image = image {
                content(Image(uiImage: image))
            } else {
                ProgressView().onAppear { loadImage() }
            }
        }
    }
    
    private func loadImage() {
        guard let url = URL(string: url) else {
            return
        }
        
        let urlRequest = URLRequest(url: url)
        
        if let cachedResponse = URLCache.shared.cachedResponse(for: urlRequest),
           let image = UIImage(data: cachedResponse.data) {
            self.image = image
        } else {
            URLSession.shared.dataTask(with: urlRequest) { data, response, error in
                if let data = data, let response = response, let image = UIImage(data: data) {
                    let cachedResponse = CachedURLResponse(response: response, data: data)
                    URLCache.shared.storeCachedResponse(cachedResponse, for: urlRequest)
                    DispatchQueue.main.async {
                        self.image = image
                    }
                }
            }.resume()
        }
    }
}

struct InternetImage_Previews: PreviewProvider
{
    static var previews: some View
    {
        InternetImage(url: "https://m.media-amazon.com/images/I/61Pf+6N6XJL.jpg") {image in
            image
                .resizable()
        }
    }
}

Comments

1

I'm late to the party, but I ran into this same problem and none of the answers in this thread solved my problem 100%, the top rated answer has too complex code for my taste and is going to be deprecated with the release of Swift 6 for inappropriate use of @Sendable other answers directly included memory leaks.

I've got down to work and I've programmed my own CachedAsyncImage.swift gist that solves all the problems I've mentioned in a simple and readable code.

Try it and let me know what you think!

The link is the following: https://gist.github.com/Gonzalo-MR8/eb66ecb949fa8d3e58ff16816d1cd9dc

And the way to use it:

        CachedAsyncImage(url: imageUrl) { phase in
        switch phase {
        case .empty:
            ProgressView()
        case .success(let image):
            image.resizable()
                .scaledToFill()
        case .failure:
            Image(.error)
        @unknown default:
            Image(.error)
        }
    }

Cheers!

1 Comment

You have put your solution on a remote resource. If you want this to not be eventually flagged as link-only answer, make sure to at least explain how your solution works.
0

Recently, I faced the same issue and discovered that the native AsyncImage in SwiftUI does not cache images as expected. It fetches the image from the network every time the view appears on screen. After some debugging, It seems like the caching behavior of AsyncImage might be broken or unreliable. I’m sharing my findings here in case it helps someone else.

According to Apple’s documentation, AsyncImage uses URLSession under the hood to fetch images. By default, URLSession uses the .useProtocolCachePolicy, which should cache the response as long as the response headers don’t explicitly disable it (e.g., with Cache-Control: no-store).


Here’s a simple SwiftUI view that uses AsyncImage to load an image:

import SwiftUI

struct ContentView: View {
    var body: some View {
        Rectangle()
            .frame(width: 400, height: 400)
            .foregroundStyle(.gray)
        AsyncImageDemo()
    }
}

struct AsyncImageDemo: View {
    let url = URL(string: "https://fastly.picsum.photos/id/1/5000/3333.jpg?hmac=Asv2DU3rA_5D1xSe22xZK47WEAN0wjWeFOhzd13ujW4")
    
    var body: some View {
        AsyncImage(url: url) { phase in
            switch phase {
            case .success(let image):
                image
                    .resizable()
                    .frame(width: 300, height: 200)
                    .aspectRatio(contentMode: .fit)
            @unknown default:
                Rectangle()
                    .frame(width: 200, height: 200)
                    .foregroundStyle(.blue)
            }
        }
    }
}

When running this code and inspecting network traffic using Charles Proxy or a similar tool, you’ll notice that AsyncImage fetches the image from the network every time the view appears.

Also, if you inspect the app’s container in the simulator, the fsCachedData folder remains empty. I’ve attached a gif demonstrating this. Sorry for adding links here as uploading images or gif fails for me everytime :(
fsCachedData folder: https://i.sstatic.net/AJx2lNy8.png
GIF: https://imgur.com/a/infyUZZ

Interestingly, if you fetch the same image using URLSession.shared manually (e.g., in the init of your view), you’ll see that the image does get cached under fsCachedData.

struct ContentView: View {
    init() {
        Task {
            let urlString = "https://fastly.picsum.photos/id/1/5000/3333.jpg?hmac=Asv2DU3rA_5D1xSe22xZK47WEAN0wjWeFOhzd13ujW4"
            let (data, response) = try await URLSession.shared.data(for: URLRequest(url: URL(string: urlString)!))
        }
    }

    var body: some View {
        Rectangle()
            .frame(width: 400, height: 400)
            .foregroundStyle(.gray)
        AsyncImageDemo()
    }
}

Once this code runs, you’ll find that the cache folder contains the image file in raw format.
fsCachedData folder: https://i.sstatic.net/f5XrOrV6.png

But here’s the interesting part: Now, when you remove the URLSession code and run the view with just AsyncImage, it stops making network calls and instead reuses the cached image.GIF AsyncImage resuing cache: https://imgur.com/a/oGBMrwv

If you delete the cached file and rerun, the network call comes back confirming that AsyncImage does rely on URLSession‘s cache if it already exists, but doesn’t seem to create or trigger it reliably on its own.

So, based on the above findings, AsyncImage appears to reuse the cache if it’s already present. But it doesn’t reliably populate the cache itself. So to tackle this issue we can go with custom cached AsyncImage as mentioned above by others or we can come up with a workaround from above findings.

Workaround

One possible workaround is to manually fetch the image once using URLSession, so that AsyncImage can reuse the cached response later. I agree this isn’t a clean solution, and it does cause two network calls on the first load (one from URLSession, one from AsyncImage). But it ensures that AsyncImage uses the cache afterward.

Here’s a small custom modifier to make that easier:

struct AsyncImageCacheModifier: ViewModifier {
    @State private var isAlreadyFetched = false
    let url: URL?
    
    init(url: URL?) {
        self.url = url
    }

    func body(content: Content) -> some View {
        content
            .task {
                guard let url, !isAlreadyFetched else { return }
                let _ = try? await URLSession.shared.data(for: URLRequest(url: url))
                isAlreadyFetched = true
            }
    }
}

extension AsyncImage {
    func enableCacheForURL(url: URL?) -> some View {
        modifier(AsyncImageCacheModifier(url: url))
    }
}

Usage:

struct AsyncImageDemo: View {
    let url = URL(string: "https://fastly.picsum.photos/id/1/5000/3333.jpg?hmac=Asv2DU3rA_5D1xSe22xZK47WEAN0wjWeFOhzd13ujW4")
    
    var body: some View {
        AsyncImage(url: url) { phase in
            switch phase {
            case .success(let image):
                image
                    .resizable()
                    .frame(width: 300, height: 200)
                    .aspectRatio(contentMode: .fit)
            @unknown default:
                Rectangle()
                    .frame(width: 200, height: 200)
                    .foregroundStyle(.blue)
            }
        }
        .enableCacheForURL(url: url)
    }
}

Again, this is just a workaround, but it might be useful if you want to stick with native AsyncImage and not getting into custom implementation. Hope this helps!

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.