← Back to list

SwiftUI • CachedAsyncImage & _ConditionalContent

We often seek elegant solutions when designing our APIs. Apple has mastered this exercice and demonstrates it to us each year. Let’s take…

Paul Bancarel · 2025-07-30 18:41 · 0 claps · 5.1 min read
#swiftui #swift #conditional #ios #ios-app-development
Open on Medium ↗
Wiki topics: 📱 · Mobile Development

SwiftUI • CachedAsyncImage & _ConditionalContent

We often seek elegant solutions when designing our APIs. Apple has mastered this exercice and demonstrates it to us each year. Let’s take a look at their native AsyncImage API.

The AsyncImage comes with two great APIs:

AsyncImage(
    url: ...,
    content: { image in <#Make your UI once the image is there#> },
    placeholder: { <#Make your UI as a placeholder waiting for the image#> }
)

AsyncImage(url: URL(string: "")!) { phase in
    switch phase {
         case .empty:
             <#code#>
         case .success(let image):
             <#code#>
         case .failure(let error):
             <#code#>
    }
}

You can use one or the other based on your need but have you ever wonder how Apple is capable of achieving such an API for his AsyncImage ? By leveraging _ConditionalContent, we can create a custom image loading view that provides a consistent, type-safe API across different loading states. This approach allows us to encapsulate complex image fetching logic while maintaining a clean, declarative syntax similar to the built-in AsyncImage, enabling developers to seamlessly handle placeholder states, loading indicators, and error scenarios with minimal boilerplate code.

Let’s try to write a CachedAsyncImage, that will store resources once downloaded to avoid re-fetching them next time they appear on screen (a thing that AsyncImage is not doing apparently after observing the traffic with *Proxyman*).

Let’s write a first init inspired by Apple’s.

public struct CachedAsyncImage<Content>: View where Content: View {
    ...
    private let url: URL?
    private let scale: CGFloat
    private let transaction: Transaction
    private let content: (AsyncImagePhase) -> Content
    ...
    public 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
    }

When we write the second one, we quickly find ourselves faced with the problem,

// THIS DOES NOT COMPILE //
public init<I, P>(
        url: URL?,
        scale: CGFloat = 1.0,
        transaction: Transaction = Transaction(),
        @ViewBuilder content: @escaping (Image) -> X,
        @ViewBuilder placeholder: @escaping () -> Y
    ) where I: View, P: View {
        self.url = url
        self.scale = scale
        self.transaction = transaction
        self.content = // How to store in my pre-defined `content: (AsyncImagePhase) -> Content`
    }

To solve this, we will need to find a way to merge the content closure into the already defined one. That’s where _ConditionalContent comes into play. _ConditionalContent is a special SwiftUI view, that is used underneath when your write view like this in your body:

var body: some View {
    if something {
       Rectangle()
    } else {
       Text("Hello world")
    }
}
// This will produce a _ConditionalContent<Rectangle, Text> underneath

This will allow us to remove any ambiguity and to pool resources:

public init<I, P>(
        url: URL?,
        scale: CGFloat = 1.0,
        transaction: Transaction = Transaction(),
        @ViewBuilder content: @escaping (Image) -> I,
        @ViewBuilder placeholder: @escaping () -> P
    ) where Content == _ConditionalContent<I, P>, I: View, P: View {
        self.url = url
        self.scale = scale
        self.transaction = transaction
        self.content = { phase in // We store a `content: (AsyncImagePhase) -> Content`
            // and for each phase we ask our client how to produce the view
            // we hide it from our client, we act as a top-layer API
            if let image = phase.image {
                ViewBuilder.buildEither(first: content(image))
            } else {
                ViewBuilder.buildEither(second: placeholder())
            }
        }
    }

For the “Cache” part we follow to the letter what Apple advice: https://developer.apple.com/tutorials/app-dev-training/caching-network-data

And here is the final file:

import SwiftUI
import OrderedCollections

public struct CachedAsyncImage<Content>: View where Content: View {
    enum DownloadPhase {
        case loading, success(Data), failure(any Error)
        var data: Data? {
            switch self {
                case .loading, .failure:
                    return nil
                case .success(let data):
                    return data
            }
        }
    }

    private let url: URL?
    private let scale: CGFloat
    private let transaction: Transaction
    private let content: (AsyncImagePhase) -> Content
    private let client = ImageClient.shared
    @State private var downloadPhase: DownloadPhase?

    public 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
    }

    public init<I, P>(
        url: URL?,
        scale: CGFloat = 1.0,
        transaction: Transaction = Transaction(),
        @ViewBuilder content: @escaping (Image) -> I,
        @ViewBuilder placeholder: @escaping () -> P
    ) where Content == _ConditionalContent<I, P>, I: View, P: View {
        self.url = url
        self.scale = scale
        self.transaction = transaction
        self.content = { phase in
            if let image = phase.image {
                ViewBuilder.buildEither(first: content(image))
            } else {
                ViewBuilder.buildEither(second: placeholder())
            }
        }
    }

    public var body: some View {
        if let url {
            content(
                asyncImagePhase(for: downloadPhase)
            )
            .task {
                withAnimation(transaction.animation) {
                    downloadPhase = .loading
                }
                do {
                    let success = try await DownloadPhase.success(client.fetch(url: url))
                    withAnimation(transaction.animation) {
                        downloadPhase = success
                    }
                } catch {
                    withAnimation(transaction.animation) {
                        downloadPhase = .failure(error)
                    }
                }
            }
        } else {
            content(
                .empty
            )
        }
    }

    private func asyncImagePhase(for downloadPhase: DownloadPhase?) -> AsyncImagePhase {
        switch downloadPhase {
            case .loading:
                return .empty
            case .success(let data):
                let image = Image(uiImage: UIImage(data: data) ?? UIImage())
                return .success(image)
            case .failure(let error):
                return .failure(error)
            case nil:
                return .empty
        }
    }
}

// https://developer.apple.com/tutorials/app-dev-training/caching-network-data
actor ImageClient {
    static let shared = ImageClient()

    final class CacheEntryObject {
        let entry: CacheEntry
        init(entry: CacheEntry) { self.entry = entry }
    }

    enum CacheEntry {
        case inProgress(Task<Data, any Error>)
        case ready(Data)
    }

    private lazy var imagesCache: NSCache<NSString, CacheEntryObject> = {
        let cache = NSCache<NSString, CacheEntryObject>()
        cache.totalCostLimit = 1024 * 1024 * 30 // 30 MB
        return cache
    }()

    private let urlSession: URLSession

    init(urlSession: URLSession = URLSession.shared) {
        self.urlSession = urlSession
    }

    func fetch(url: URL) async throws -> Data {
        try await fetchOrCache(for: url)
    }

    private func fetchOrCache(for url: URL) async throws -> Data {
        if let cached = imagesCache[url] {
            switch cached {
                case .ready(let data):
                    return data
                case .inProgress(let task):
                    return try await task.value
            }
        }
        let task = Task<Data, any Error> {
            let data = try await urlSession.data(from: url).0
            return data
        }
        imagesCache[url] = .inProgress(task)
        do {
            let data = try await task.value
            imagesCache[url] = .ready(data)
            return data
        } catch {
            imagesCache[url] = nil
            throw error
        }
    }
}

extension NSCache where KeyType == NSString, ObjectType == ImageClient.CacheEntryObject {
    subscript(_ url: URL) -> ImageClient.CacheEntry? {
        get {
            let key = url.absoluteString as NSString
            let value = object(forKey: key)
            return value?.entry
        }
        set {
            let key = url.absoluteString as NSString
            if let entry = newValue {
                let value = ImageClient.CacheEntryObject(entry: entry)
                setObject(value, forKey: key)
            } else {
                removeObject(forKey: key)
            }
        }
    }
}

Pro(s):

  • You can’t start 2 downloads of the same resource (thanks to actor) by accident: If you land on a view that have to download twice the same image, even if 1st download is not finished the 2nd will detect you try to get the same resource and will wait that the 1st started task finish before sharing this resource to the two asking views.

Con(s):

  • CacheAsyncImage as AsyncImage use a switch internally to display different views that corresponds to different states (empty, success, failure) as a result structural identity is not maintained which can give you weird behaviors when playing animation that change position of the image if download is not finished yet.

Futur:

We can update the code a bit to rely on a disk storage instead of keeping contents in a local var that will be cleared when the app close when building our cache.

Conclusion

Optimizing remote asset resources is crucial for developing high-performance applications, as it directly impacts user experience, application responsiveness, and resource efficiency. By implementing intelligent caching mechanisms, developers can significantly reduce network load, minimize redundant downloads, and create seamless user interactions. _ConditionalContent offers us enough flexibility to create richer API that will hide this complexity to the end developer and it is very much appreciated.

Thanks for reading the article. If you have a bit of time left, feel free to take a look at the project I’m currently working on: Pulldog.


메타데이터
post_id
19f27ca89dff
slug
swiftui-cachedasyncimage-conditionalcontent-19f27ca89dff
url
https://medium.com/@bancarel.paul/swiftui-cachedasyncimage-conditionalcontent-19f27ca89dff
canonical_url
https://medium.com/@bancarel.paul/swiftui-cachedasyncimage-conditionalcontent-19f27ca89dff
author_url
https://medium.com/@bancarel.paul
status
ok
fetched_at
2026-07-09 04:10:03