38

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.

Luis Ramirez
  • 966
  • 1
  • 8
  • 25
  • check out [this article](https://www.vadimbulavin.com/asynchronous-swiftui-image-loading-from-url-with-combine-and-swift/) about making really simple `AsyncImage` for iOS 14 which supports caching. – Phil Dukhov Sep 16 '21 at 21:11
  • I am afraid that you can't, but check this component it has what you need: https://github.com/kean/NukeUI – FarouK Sep 16 '21 at 21:12

6 Answers6

54

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.

Gobe
  • 2,559
  • 1
  • 25
  • 24
Lorenzo Fiamingo
  • 3,251
  • 2
  • 17
  • 35
  • 2
    Perfect solution – nightwill Jan 11 '22 at 07:06
  • 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 – occulus Mar 16 '22 at 11:12
  • @occulus It uses the default Foundation `URLSession` with a `URLCache`, so this depends on apple implementation. – Lorenzo Fiamingo Mar 20 '22 at 13:23
  • 8
    This library does not work. I'm testing it out and it fetches a remote image everytime when using a `List` – Dr. Mr. Uncle May 09 '22 at 15:47
  • @LorenzoFiamingo Apple's URLCache never handles that scenario whenever it occurs, so I guess this doesn't either – occulus Aug 01 '22 at 17:17
  • @Dr. Mr. Uncle yes the library doesn't work – JAHelia Sep 26 '22 at 12:06
  • 1
    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 – heyfrank May 20 '23 at 14:03
9

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
        }
    }
}
Ryan Fung
  • 2,069
  • 9
  • 38
  • 60
8

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
I. Pedan
  • 244
  • 2
  • 9
  • 9
    Do you have a source that confirms this? All the other answers imply there's no caching and you should add it yourself. – mojuba Dec 26 '22 at 18:20
  • There is nothing listed in the Developer Documentation stating that AsyncImage uses the default URLCache. Pedan can you cite your sources for this? – stromyc Mar 07 '23 at 14:12
  • 2
    @stromyc actually the developer documentation clearly said that AsyncImage uses shared URLSession. Further I have tested this and this works. – Ivan Ičin Mar 11 '23 at 13:31
  • 1
    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. – Gerry Shaw Jun 13 '23 at 21:05
5

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.

valvoline
  • 7,737
  • 3
  • 47
  • 52
  • Thanks Valvoline. Yes, I have resorted to using my own solution utilizing Combine and directly working with the cachedirectory. – stromyc Mar 12 '23 at 17:30
  • 1
    Tried all answers, this one is the first working straight out of the box. Thanks! iOS 16.4, Xcode 14.3 – Obl Tobl Apr 13 '23 at 06:32
0

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)
    }
}
Den
  • 3,179
  • 29
  • 26
0
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()
        }
    }
}
Dmitry
  • 242
  • 1
  • 9
  • 20