Swift and SwiftUI tutorials for Swift Developers

Image Caching in SwiftUI

As an iOS Developer, you know that performance and user experience are everything. One of the fastest ways to ruin a great application is making users wait endlessly for images to load, or worse, consuming their mobile data by downloading the same image over and over every time they scroll. This is where Image Caching in SwiftUI comes into play.

In this comprehensive tutorial, we will dive deep into Swift programming to build a robust, efficient, and cross-platform image caching system. We will learn how to overcome native limitations, write clean code in Swift, and structure our project in Xcode so it works flawlessly on iOS, macOS, and watchOS.


1. What is Image Caching and why every iOS Developer needs it?

Caching is the process of temporarily storing data in a fast-access location (RAM or local disk storage) so that future requests for that same data can be served much faster.

In the context of Swift programming applied to visual development, when you download an image from a URL, your app makes a network request. If the user leaves that screen and comes back, making another network request is a waste of resources. A good Image Caching in SwiftUI system saves that image the first time it is downloaded. The next time it’s needed, the app retrieves it from the cache instantly.

The two levels of cache:

  1. Memory Cache: Extremely fast, but volatile. It is stored in RAM. If the app is closed or the operating system needs to free up memory, this cache is cleared. We will use NSCache for this.
  2. Disk Cache: Slower than memory, but persistent. Data is saved in the device’s storage (like the iPhone or Mac’s SSD). It survives app restarts.

2. The problem with AsyncImage in SwiftUI

If you’ve worked with SwiftUI since iOS 15, macOS 12, or watchOS 8, you probably know AsyncImage. It’s fantastic for loading images quickly:

AsyncImage(url: URL(string: "https://example.com/image.jpg")) { image in
    image.resizable()
} placeholder: {
    ProgressView()
}

The harsh reality: AsyncImage does not have its own memory cache. That’s right. If you put an AsyncImage inside a List or ScrollView, every time the image goes off and back on the screen, the system might download it again (or at best, rely on URLSession‘s underlying and opaque cache, which isn’t optimized for instant UI rendering). As a professional iOS Developer, you cannot rely solely on this for production apps.


3. Setting the stage: Cross-platform in Xcode

Before writing our cache system, we must address the elephant in the room: iOS and watchOS use UIImage (from the UIKit/WatchKit framework), while macOS uses NSImage (from the AppKit framework).

To make our Swift code truly cross-platform in Xcode, we’ll create an alias. Open Xcode, create a new Swift file named CrossPlatformImage.swift, and add the following:

import SwiftUI

#if os(macOS)
import AppKit
public typealias PlatformImage = NSImage
#else
import UIKit
public typealias PlatformImage = UIImage
#endif

// Extension to easily convert PlatformImage to a SwiftUI View
extension Image {
    init(platformImage: PlatformImage) {
        #if os(macOS)
        self.init(nsImage: platformImage)
        #else
        self.init(uiImage: platformImage)
        #endif
    }
}

With this, we can refer to PlatformImage throughout our project, and the Swift compiler will know exactly what to use on each operating system.


4. Step 1: Creating the Cache Manager (ImageCacheManager)

We are going to use NSCache. Unlike a simple dictionary (Dictionary in Swift), NSCache is thread-safe and automatically evicts objects from memory if Apple’s system runs out of RAM, preventing your app from freezing or crashing due to lack of resources.

Create a file named ImageCacheManager.swift:

import Foundation

final class ImageCacheManager {
    // Singleton for global access
    static let shared = ImageCacheManager()
    
    // Our NSCache. We use NSString as key (the URL) and PlatformImage as value
    private let memoryCache: NSCache<NSString, PlatformImage>
    
    private init() {
        memoryCache = NSCache<NSString, PlatformImage>()
        // Optional: Configure limits to avoid consuming too much RAM
        memoryCache.countLimit = 100 // Max 100 images in memory
        memoryCache.totalCostLimit = 1024 * 1024 * 100 // Limit of ~100 MB
    }
    
    func set(_ image: PlatformImage, forKey key: String) {
        let nsKey = NSString(string: key)
        memoryCache.setObject(image, forKey: nsKey)
    }
    
    func get(forKey key: String) -> PlatformImage? {
        let nsKey = NSString(string: key)
        return memoryCache.object(forKey: nsKey)
    }
}

5. Step 2: The Image Loader (ImageLoader)

Now we need a class that handles downloading the image if it’s not in the cache, and saving it once downloaded. We’ll leverage the power of async/await in modern Swift programming, which makes our asynchronous code incredibly clean.

Create a file named ImageLoader.swift:

import Foundation
import Combine

@MainActor // Ensures state changes are published on the main thread for SwiftUI
class ImageLoader: ObservableObject {
    @Published var image: PlatformImage?
    @Published var isLoading = false
    @Published var error: Error?
    
    private let cache = ImageCacheManager.shared
    
    func load(from urlString: String) async {
        guard let url = URL(string: urlString) else { return }
        
        // 1. Check memory cache first
        if let cachedImage = cache.get(forKey: urlString) {
            self.image = cachedImage
            return
        }
        
        // 2. If not in cache, download it
        isLoading = true
        
        do {
            let (data, response) = try await URLSession.shared.data(from: url)
            
            // Validate HTTP response
            guard let httpResponse = response as? HTTPURLResponse, 
                  (200...299).contains(httpResponse.statusCode) else {
                throw URLError(.badServerResponse)
            }
            
            // 3. Create the image and save it to cache
            if let downloadedImage = PlatformImage(data: data) {
                self.cache.set(downloadedImage, forKey: urlString)
                self.image = downloadedImage
            }
            
        } catch {
            self.error = error
            print("Error downloading image: \(error.localizedDescription)")
        }
        
        isLoading = false
    }
}

6. Step 3: Building our CachedAsyncImage view in SwiftUI

This is where all the magic of Swift programming comes together. We’re going to create a view that works very similarly to the native AsyncImage, but uses our ImageLoader on steroids.

Create CachedAsyncImage.swift:

import SwiftUI

struct CachedAsyncImage<Content: View, Placeholder: View>: View {
    private let url: String
    private let content: (Image) -> Content
    private let placeholder: () -> Placeholder
    
    @StateObject private var loader = ImageLoader()
    
    // Initializer that accepts closures to customize how the image and placeholder look
    init(
        url: String,
        @ViewBuilder content: @escaping (Image) -> Content,
        @ViewBuilder placeholder: @escaping () -> Placeholder
    ) {
        self.url = url
        self.content = content
        self.placeholder = placeholder
    }
    
    var body: some View {
        ZStack {
            if let platformImage = loader.image {
                // We use our extension to convert PlatformImage to SwiftUI.Image
                content(Image(platformImage: platformImage))
            } else if loader.error != nil {
                // Visual error handling
                Image(systemName: "photo.badge.exclamationmark")
                    .foregroundColor(.red)
            } else {
                placeholder()
            }
        }
        // We use .task, which natively supports async/await and cancels if the view disappears
        .task {
            await loader.load(from: url)
        }
    }
}

How to use it in your UI?

The usage is now identical to the native API, but infinitely more efficient:

struct ContentView: View {
    let imageUrls = [
        "https://example.com/photo1.jpg",
        "https://example.com/photo2.jpg"
    ]
    
    var body: some View {
        ScrollView {
            VStack {
                ForEach(imageUrls, id: \.self) { urlString in
                    CachedAsyncImage(url: urlString) { image in
                        image
                            .resizable()
                            .scaledToFill()
                            .frame(height: 200)
                            .clipShape(RoundedRectangle(cornerRadius: 15))
                    } placeholder: {
                        ProgressView()
                            .frame(height: 200)
                            .frame(maxWidth: .infinity)
                            .background(Color.gray.opacity(0.2))
                            .clipShape(RoundedRectangle(cornerRadius: 15))
                    }
                    .padding(.horizontal)
                }
            }
        }
        .navigationTitle("Image Caching in SwiftUI")
    }
}

7. Leveling Up: Implementing Disk Caching

So far, our Image Caching in SwiftUI uses RAM. If the user closes your app on iOS or macOS and re-enters, the images will be downloaded again. For a commercial app, this is not enough. We must save the data to the device’s file system using FileManager.

Let’s add disk capabilities to our ImageCacheManager.

import Foundation

final class ImageCacheManager {
    static let shared = ImageCacheManager()
    private let memoryCache: NSCache<NSString, PlatformImage>
    private let fileManager = FileManager.default
    
    // Directory where we will save images on disk
    private var cacheDirectory: URL? {
        fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first
    }
    
    private init() {
        memoryCache = NSCache<NSString, PlatformImage>()
        memoryCache.countLimit = 100
    }
    
    // Helper function to create a safe file name from a URL
    private func getFilePath(forKey key: String) -> URL? {
        guard let folder = cacheDirectory, let url = URL(string: key) else { return nil }
        let fileName = url.lastPathComponent // Ex: "photo1.jpg"
        // In a real app, it's better to use a hash (MD5 or SHA256) of the string to avoid name collisions
        return folder.appendingPathComponent(fileName)
    }
    
    func set(_ image: PlatformImage, forKey key: String) {
        // 1. Save to Memory
        let nsKey = NSString(string: key)
        memoryCache.setObject(image, forKey: nsKey)
        
        // 2. Save to Disk asynchronously to avoid blocking the main thread
        Task.detached(priority: .background) { [weak self] in
            guard let self = self, let fileURL = self.getFilePath(forKey: key) else { return }
            
            #if os(macOS)
            guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
            let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
            let data = bitmapRep.representation(using: .jpeg, properties: [:])
            #else
            let data = image.jpegData(compressionQuality: 0.8)
            #endif
            
            try? data?.write(to: fileURL)
        }
    }
    
    func get(forKey key: String) -> PlatformImage? {
        // 1. Search in Memory first (it's faster)
        let nsKey = NSString(string: key)
        if let memoryImage = memoryCache.object(forKey: nsKey) {
            return memoryImage
        }
        
        // 2. If not in memory, search on Disk
        if let fileURL = getFilePath(forKey: key),
           let data = try? Data(contentsOf: fileURL),
           let diskImage = PlatformImage(data: data) {
            
            // If found on disk, we upload it back to memory for fast future access
            memoryCache.setObject(diskImage, forKey: nsKey)
            return diskImage
        }
        
        return nil // Not in memory nor on disk
    }
}

By implementing this improvement, your app now behaves like top-tier applications (Instagram, Twitter, etc.). It first queries the RAM; if it fails, it queries the SSD; and only if both fail, does it make the expensive network call.


8. Cross-platform Considerations in watchOS and macOS

When developing using SwiftUI in Xcode with the full ecosystem in mind, there are performance subtleties you must observe:

  • watchOS: Memory and disk space are extremely limited on the Apple Watch. If you develop for watchOS, you should drastically reduce the countLimit of the NSCache (for example, to 20 images) and perhaps skip disk storage unless strictly necessary.
  • macOS: Resources are abundant here. However, handling NSImage versus UIImage can be tricky. Notice how in step 7 we use NSBitmapImageRep in the #if os(macOS) block. This is because NSImage does not have a direct .jpegData function like UIImage does in iOS. This is crucial knowledge that separates a beginner iOS Developer from an advanced one in universal Swift programming.

Conclusion

Mastering Image Caching in SwiftUI is a mandatory step in your career as an iOS Developer. Relying solely on AsyncImage or repetitively downloading images creates severe bottlenecks.

We have built from scratch a system that leverages the best of Swift programming:

  1. Smart cross-platform compatibility using compiler directives (#if os).
  2. Modern concurrent handling using async/await.
  3. A two-tier cache architecture (Memory and Disk) using NSCache and FileManager.
  4. A clean and declarative SwiftUI interface, encapsulating complexity so using it in your app is as easy as typing CachedAsyncImage(url: "...").

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

GlassEffectContainer in SwiftUI

Next Article

navigationDocument in SwiftUI

Related Posts