
Image Loading
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates a Swift image loading pipeline with memory/disk caching, request deduplication, and a drop-in CachedAsyncImage SwiftUI view.
About
Generates a production image loading pipeline with NSCache memory cache, LRU disk cache, deduplication, image processing, and a CachedAsyncImage view. A developer uses it to replace AsyncImage or add image caching and prefetching to an Apple app.
- Drop-in CachedAsyncImage replacement for AsyncImage
- Detects and coexists with Kingfisher, SDWebImage, or Nuke
Image Loading by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill image-loadingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates a Swift image loading pipeline with memory/disk caching, request deduplication, and a drop-in CachedAsyncImage SwiftUI view.
Files
Image Loading Generator
Generate a production image loading pipeline with NSCache memory cache, LRU disk cache, request deduplication, image processing, and a drop-in CachedAsyncImage SwiftUI view.
When This Skill Activates
Use this skill when the user:
- Asks to "add image caching" or "cache images"
- Wants to "replace AsyncImage" or fix "AsyncImage has no cache"
- Mentions "image loading pipeline" or "lazy image loading"
- Asks about "image download" or "image prefetching"
- Wants "thumbnail generation" or "image resizing"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Check for @Observable support (iOS 17+ / macOS 14+)
- [ ] Identify source file locations
2. Conflict Detection
Search for existing image loading:
Glob: **/*ImageCache*.swift, **/*ImageLoader*.swift, **/*ImagePipeline*.swift
Grep: "AsyncImage" or "UIImage" or "NSImage" or "ImageCache"If third-party library found (Kingfisher, SDWebImage, Nuke):
- Ask if user wants to replace or keep it
- If keeping, don't generate — advise on best practices instead
3. Platform Detection
Determine if generating for iOS (UIImage) or macOS (NSImage) or both (cross-platform typealias).
Configuration Questions
Ask user via AskUserQuestion:
1. Cache sizes?
- Small (50 MB memory / 100 MB disk)
- Medium (100 MB memory / 250 MB disk) — recommended
- Large (200 MB memory / 500 MB disk)
2. Image processing?
- Resize to fit (downscale large images to save memory)
- Thumbnail generation (create small thumbnails for lists)
- None (cache original images only)
3. Additional features? (multi-select)
- Prefetching for collections (preload images for visible rows + buffer)
- Placeholder and error images
- Progress indicator during download
4. Platform?
- iOS only
- macOS only
- Cross-platform (iOS + macOS)
Generation Process
Step 1: Read Templates
Read image-loading-patterns.md for architecture guidance. Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. ImageCache.swift — Protocol for cache interface 2. MemoryImageCache.swift — NSCache-based with configurable size 3. DiskImageCache.swift — FileManager LRU with expiration 4. ImageDownloader.swift — Actor-based with deduplication + cancellation 5. ImagePipeline.swift — Orchestrator (cache → download → process → store)
Step 3: Create UI Files
6. CachedAsyncImage.swift — Drop-in SwiftUI view replacement
Step 4: Create Optional Files
Based on configuration:
ImageProcessor.swift— If resize or thumbnail selectedImagePrefetcher.swift— If prefetching selected
Step 5: Determine File Location
Check project structure:
- If
Sources/exists →Sources/ImageLoading/ - If
App/exists →App/ImageLoading/ - Otherwise →
ImageLoading/
Output Format
After generation, provide:
Files Created
ImageLoading/
├── ImageCache.swift # Protocol for cache interface
├── MemoryImageCache.swift # NSCache-based memory cache
├── DiskImageCache.swift # LRU disk cache with expiration
├── ImageDownloader.swift # Actor-based downloader
├── ImagePipeline.swift # Orchestrator
├── ImageProcessor.swift # Resize, thumbnails (optional)
├── CachedAsyncImage.swift # SwiftUI view
└── ImagePrefetcher.swift # Collection prefetching (optional)Integration Steps
Drop-in replacement for AsyncImage:
// Before (no caching)
AsyncImage(url: user.avatarURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
// After (with caching)
CachedAsyncImage(url: user.avatarURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}In a List:
List(users) { user in
HStack {
CachedAsyncImage(url: user.avatarURL) { image in
image.resizable().frame(width: 44, height: 44).clipShape(Circle())
} placeholder: {
Circle().fill(Color.secondary.opacity(0.2)).frame(width: 44, height: 44)
}
Text(user.name)
}
}With prefetching:
struct UsersListView: View {
let users: [User]
@State private var prefetcher = ImagePrefetcher()
var body: some View {
List(users) { user in
UserRow(user: user)
.onAppear { prefetcher.startPrefetching(urls: nearbyURLs(for: user)) }
.onDisappear { prefetcher.stopPrefetching(urls: [user.avatarURL]) }
}
}
}With image processing:
CachedAsyncImage(
url: photo.url,
processing: .resize(targetSize: CGSize(width: 300, height: 300))
) { image in
image.resizable()
} placeholder: {
Color.secondary.opacity(0.2)
}Testing
@Test
func cachedImageReturnedWithoutDownload() async throws {
let cache = InMemoryImageCache()
let downloader = MockImageDownloader()
let pipeline = ImagePipeline(cache: cache, downloader: downloader)
let testImage = PlatformImage.testImage
await cache.store(testImage, for: testURL)
let result = try await pipeline.image(for: testURL)
#expect(result != nil)
#expect(downloader.downloadCount == 0) // Cache hit
}
@Test
func deduplicatesConcurrentRequests() async throws {
let downloader = MockImageDownloader(delay: .milliseconds(100))
let pipeline = ImagePipeline(downloader: downloader)
async let image1 = pipeline.image(for: testURL)
async let image2 = pipeline.image(for: testURL)
let results = try await [image1, image2]
#expect(results.count == 2)
#expect(downloader.downloadCount == 1) // Only one download
}References
- image-loading-patterns.md — Why not AsyncImage, NSCache config, LRU disk cache, deduplication
- templates.md — All production Swift templates
- Related:
generators/http-cache— General HTTP response caching - Related:
generators/pagination— Prefetch images in paginated lists
Image Loading Patterns and Best Practices
Why Not Just AsyncImage?
AsyncImage is convenient but has critical limitations:
| Feature | AsyncImage | Custom Pipeline |
|---|---|---|
| Memory cache | ❌ None | ✅ NSCache |
| Disk cache | ❌ None | ✅ LRU FileManager |
| Request deduplication | ❌ Each view downloads independently | ✅ Single download shared |
| Prefetching | ❌ Not possible | ✅ Preload before visible |
| Image processing | ❌ Returns original size | ✅ Resize, thumbnail |
| Cancel on disappear | ⚠️ Inconsistent | ✅ Explicit cancellation |
| Memory pressure handling | ❌ No eviction | ✅ NSCache auto-evicts |
| Offline support | ❌ None | ✅ Serve from disk cache |
| Cache key customization | ❌ None | ✅ Custom keys |
Rule of thumb: Use AsyncImage for prototypes and low-traffic views. Use a custom pipeline for anything with lists, grids, or many images.
Architecture Overview
CachedAsyncImage (SwiftUI View)
└── ImagePipeline (Orchestrator)
├── MemoryImageCache (NSCache)
│ └── Hit? → Return immediately
├── DiskImageCache (FileManager)
│ └── Hit? → Decode, store in memory, return
├── ImageDownloader (Actor)
│ ├── Deduplicate concurrent requests
│ ├── Download via URLSession
│ └── Return Data
└── ImageProcessor (Optional)
├── Resize to fit
├── Generate thumbnail
└── Return processed imageNSCache Configuration
Why NSCache Over Dictionary
- Automatic eviction on memory pressure
- Thread-safe without manual locking
- Cost-based — assign cost = byte size of image
- Count limit — cap maximum entries
Configuration
let cache = NSCache<NSString, PlatformImage>()
cache.countLimit = 200 // Max 200 images
cache.totalCostLimit = 100 * 1024 * 1024 // 100 MB max
// Store with cost = image byte size
let cost = image.pngData()?.count ?? 0
cache.setObject(image, forKey: key as NSString, cost: cost)Memory Pressure
NSCache automatically evicts objects when the system is under memory pressure. You don't need to handle UIApplication.didReceiveMemoryWarningNotification — NSCache does it for you.
However, for disk cache, listen for memory warnings to clear the memory layer:
NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: .main
) { _ in
memoryCache.removeAllObjects()
}LRU Disk Cache
File Structure
CachesDirectory/
└── ImageCache/
├── a1b2c3d4.jpg # Hashed filename
├── e5f6g7h8.jpg
└── ...Key Design Decisions
1. Use Caches directory — system can clear it, not backed up to iCloud 2. Hash URL for filename — SHA-256 avoids path conflicts, special characters 3. Track access dates — for LRU eviction, use file system's content access date 4. Background eviction — don't evict synchronously during load
Expiration
Two eviction strategies:
1. Size-based (LRU): When total cache exceeds limit, remove least-recently-accessed files 2. Time-based (TTL): Remove files older than N days (e.g., 7 days)
func evictExpired(maxAge: TimeInterval = 7 * 24 * 3600) {
let cutoff = Date().addingTimeInterval(-maxAge)
for file in allCachedFiles() {
if file.modificationDate < cutoff {
try? FileManager.default.removeItem(at: file.url)
}
}
}Request Deduplication
The Problem
A list with 10 visible cells showing the same user avatar = 10 simultaneous downloads for the same URL.
The Solution: Actor-Based Coalescing
actor ImageDownloader {
private var activeDownloads: [URL: Task<Data, Error>] = [:]
func download(url: URL) async throws -> Data {
// If already downloading this URL, wait for existing task
if let existing = activeDownloads[url] {
return try await existing.value
}
let task = Task {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
activeDownloads[url] = task
do {
let data = try await task.value
activeDownloads.removeValue(forKey: url)
return data
} catch {
activeDownloads.removeValue(forKey: url)
throw error
}
}
}Why an Actor?
- Thread-safe — no data races on
activeDownloads - Serialized access — checks for existing download before starting new one
- Automatic cleanup — remove completed tasks
Image Processing
When to Process
| Scenario | Process? | Why |
|---|---|---|
| 4000x3000 photo in a 44x44 avatar | ✅ Resize | Saves ~95% memory |
| Icon from CDN already sized | ❌ | Already optimized |
| Photo gallery (full screen) | ⚠️ Maybe | Resize to screen size, not original |
| Thumbnail in list | ✅ Thumbnail | Small size for fast scrolling |
Memory Impact
A 4000x3000 photo at 4 bytes/pixel = 48 MB in memory. Resized to 88x88 (2x for Retina) = 31 KB. That's a 1500x reduction.
Resize with UIGraphicsImageRenderer
func resize(_ image: UIImage, to targetSize: CGSize) -> UIImage {
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}Thumbnail with ImageIO (Most Efficient)
import ImageIO
func thumbnail(from data: Data, maxPixelSize: Int) -> CGImage? {
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true
]
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
return CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
}ImageIO is more efficient than UIGraphicsImageRenderer because:
- Doesn't decode full image into memory first
- Operates on compressed data directly
- Uses hardware acceleration
Prefetching for Collections
How It Works
Preload images for rows that are about to become visible:
// User sees rows 5-15
// Prefetch rows 15-25 (next screen)
// Cancel rows 0-4 (scrolled off)UICollectionView Integration
// UIKit has built-in prefetching
extension ViewController: UICollectionViewDataSourcePrefetching {
func collectionView(_ collectionView: UICollectionView,
prefetchItemsAt indexPaths: [IndexPath]) {
let urls = indexPaths.map { items[$0.row].imageURL }
prefetcher.startPrefetching(urls: urls)
}
func collectionView(_ collectionView: UICollectionView,
cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
let urls = indexPaths.map { items[$0.row].imageURL }
prefetcher.stopPrefetching(urls: urls)
}
}SwiftUI Prefetching
SwiftUI doesn't have built-in prefetching. Use onAppear/onDisappear:
ForEach(items) { item in
ItemRow(item: item)
.onAppear { prefetcher.startPrefetching(urls: nearbyURLs(for: item)) }
.onDisappear { prefetcher.stopPrefetching(urls: [item.imageURL]) }
}Cross-Platform Support
Platform Image Typealias
#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endifPlatform-Specific Extensions
extension PlatformImage {
var cgImageValue: CGImage? {
#if canImport(UIKit)
return cgImage
#elseif canImport(AppKit)
return cgImage(forProposedRect: nil, context: nil, hints: nil)
#endif
}
static func from(data: Data) -> PlatformImage? {
#if canImport(UIKit)
return UIImage(data: data)
#elseif canImport(AppKit)
return NSImage(data: data)
#endif
}
}Anti-Patterns to Avoid
Don't Decode on Main Thread
// ❌ Blocks UI while decoding large image
let image = UIImage(data: downloadedData)!
imageView.image = image
// ✅ Decode in background, display on main
Task.detached {
let image = UIImage(data: downloadedData)
// Force decode
_ = image?.cgImage?.dataProvider?.data
await MainActor.run { imageView.image = image }
}Don't Cache Full-Size Images When Displaying Thumbnails
// ❌ 48 MB per image in cache
cache.setObject(fullSizeImage, forKey: url)
// ✅ Cache at display size
let resized = resize(fullSizeImage, to: displaySize)
cache.setObject(resized, forKey: "\(url)_\(displaySize)")Don't Ignore Memory Warnings
// ❌ Memory cache grows unbounded
var cache: [URL: UIImage] = [:] // Dictionary doesn't auto-evict
// ✅ NSCache auto-evicts under memory pressure
let cache = NSCache<NSString, UIImage>()
cache.totalCostLimit = 100 * 1024 * 1024Don't Re-Download On Every Appear
// ❌ Downloads every time cell appears
.onAppear { Task { image = await download(url) } }
// ✅ Check cache first
.task { image = await pipeline.image(for: url) } // Cache-firstTesting Image Loading
Mock Downloader
actor MockImageDownloader: ImageDownloading {
var downloadCount = 0
var responses: [URL: Data] = [:]
var delay: Duration?
func download(url: URL) async throws -> Data {
if let delay { try await Task.sleep(for: delay) }
downloadCount += 1
guard let data = responses[url] else {
throw URLError(.badServerResponse)
}
return data
}
}Test Image Helper
extension PlatformImage {
static var testImage: PlatformImage {
let size = CGSize(width: 100, height: 100)
#if canImport(UIKit)
return UIGraphicsImageRenderer(size: size).image { ctx in
UIColor.red.setFill()
ctx.fill(CGRect(origin: .zero, size: size))
}
#elseif canImport(AppKit)
let image = NSImage(size: size)
image.lockFocus()
NSColor.red.setFill()
NSRect(origin: .zero, size: size).fill()
image.unlockFocus()
return image
#endif
}
}Image Loading Code Templates
Production-ready Swift templates for image loading pipeline. All code targets iOS 16+ / macOS 13+ (iOS 17+ / macOS 14+ for @Observable) and uses modern Swift concurrency.
Platform Compatibility
#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endifImageCache.swift
import Foundation
/// Protocol for image cache storage.
///
/// Implementations handle memory and/or disk caching.
protocol ImageCaching: Sendable {
func image(for url: URL) async -> PlatformImage?
func store(_ image: PlatformImage, for url: URL) async
func remove(for url: URL) async
func removeAll() async
}MemoryImageCache.swift
import Foundation
/// NSCache-based memory image cache.
///
/// - Auto-evicts under memory pressure (NSCache behavior)
/// - Thread-safe without manual locking
/// - Configurable count and cost limits
final class MemoryImageCache: ImageCaching, @unchecked Sendable {
private let cache = NSCache<NSString, PlatformImage>()
init(countLimit: Int = 200, totalCostLimit: Int = 100 * 1024 * 1024) {
cache.countLimit = countLimit
cache.totalCostLimit = totalCostLimit
}
func image(for url: URL) async -> PlatformImage? {
cache.object(forKey: url.absoluteString as NSString)
}
func store(_ image: PlatformImage, for url: URL) async {
let cost = estimatedCost(of: image)
cache.setObject(image, forKey: url.absoluteString as NSString, cost: cost)
}
func remove(for url: URL) async {
cache.removeObject(forKey: url.absoluteString as NSString)
}
func removeAll() async {
cache.removeAllObjects()
}
private func estimatedCost(of image: PlatformImage) -> Int {
#if canImport(UIKit)
guard let cgImage = image.cgImage else { return 0 }
return cgImage.bytesPerRow * cgImage.height
#elseif canImport(AppKit)
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return 0 }
return cgImage.bytesPerRow * cgImage.height
#endif
}
}DiskImageCache.swift
import Foundation
import CryptoKit
/// Disk-backed image cache with LRU eviction and TTL expiration.
///
/// Stores images as JPEG files in the Caches directory.
/// Uses SHA-256 hashed filenames to avoid path conflicts.
actor DiskImageCache: ImageCaching {
private let cacheDirectory: URL
private let maxDiskSize: Int
private let maxAge: TimeInterval
init(
directoryName: String = "ImageCache",
maxDiskSize: Int = 250 * 1024 * 1024,
maxAge: TimeInterval = 7 * 24 * 3600 // 7 days
) {
self.cacheDirectory = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent(directoryName, isDirectory: true)
self.maxDiskSize = maxDiskSize
self.maxAge = maxAge
try? FileManager.default.createDirectory(
at: cacheDirectory,
withIntermediateDirectories: true
)
}
func image(for url: URL) async -> PlatformImage? {
let fileURL = fileURL(for: url)
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
// Check expiration
if let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let modified = attrs[.modificationDate] as? Date,
Date().timeIntervalSince(modified) > maxAge {
try? FileManager.default.removeItem(at: fileURL)
return nil
}
// Touch access date for LRU
try? FileManager.default.setAttributes(
[.modificationDate: Date()],
ofItemAtPath: fileURL.path
)
guard let data = try? Data(contentsOf: fileURL) else { return nil }
return PlatformImage(data: data)
}
func store(_ image: PlatformImage, for url: URL) async {
let fileURL = fileURL(for: url)
#if canImport(UIKit)
guard let data = image.jpegData(compressionQuality: 0.8) else { return }
#elseif canImport(AppKit)
guard let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let data = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.8]) else { return }
#endif
try? data.write(to: fileURL)
await evictIfNeeded()
}
func remove(for url: URL) async {
let fileURL = fileURL(for: url)
try? FileManager.default.removeItem(at: fileURL)
}
func removeAll() async {
try? FileManager.default.removeItem(at: cacheDirectory)
try? FileManager.default.createDirectory(
at: cacheDirectory,
withIntermediateDirectories: true
)
}
// MARK: - Private
private func fileURL(for url: URL) -> URL {
let hash = SHA256.hash(data: Data(url.absoluteString.utf8))
let filename = hash.compactMap { String(format: "%02x", $0) }.joined()
return cacheDirectory.appendingPathComponent(filename)
}
private func evictIfNeeded() async {
let fileManager = FileManager.default
guard let files = try? fileManager.contentsOfDirectory(
at: cacheDirectory,
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
options: .skipsHiddenFiles
) else { return }
let totalSize = files.reduce(0) { total, url in
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
return total + size
}
guard totalSize > maxDiskSize else { return }
// LRU: sort by modification date (oldest first)
let sorted = files.sorted { a, b in
let dateA = (try? a.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
let dateB = (try? b.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate ?? .distantPast
return dateA < dateB
}
var currentSize = totalSize
for url in sorted {
guard currentSize > maxDiskSize else { break }
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
try? fileManager.removeItem(at: url)
currentSize -= size
}
}
}ImageDownloader.swift
import Foundation
/// Protocol for image downloading.
protocol ImageDownloading: Actor {
func download(url: URL) async throws -> Data
func cancel(url: URL)
}
/// Actor-based image downloader with request deduplication.
///
/// If multiple views request the same URL simultaneously,
/// only one download occurs and all callers receive the result.
actor ImageDownloader: ImageDownloading {
private var activeDownloads: [URL: Task<Data, Error>] = [:]
private let session: URLSession
init(session: URLSession? = nil) {
let config = URLSessionConfiguration.default
config.urlCache = nil // We handle caching ourselves
self.session = session ?? URLSession(configuration: config)
}
func download(url: URL) async throws -> Data {
// Deduplicate: if already downloading, wait for existing task
if let existing = activeDownloads[url] {
return try await existing.value
}
let task = Task<Data, Error> {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw ImageLoadingError.downloadFailed(url: url)
}
return data
}
activeDownloads[url] = task
do {
let data = try await task.value
activeDownloads.removeValue(forKey: url)
return data
} catch {
activeDownloads.removeValue(forKey: url)
throw error
}
}
func cancel(url: URL) {
activeDownloads[url]?.cancel()
activeDownloads.removeValue(forKey: url)
}
}
/// Errors specific to image loading.
enum ImageLoadingError: Error, LocalizedError {
case downloadFailed(url: URL)
case decodingFailed(url: URL)
case processingFailed
var errorDescription: String? {
switch self {
case .downloadFailed(let url):
return "Failed to download image from \(url.absoluteString)"
case .decodingFailed(let url):
return "Failed to decode image from \(url.absoluteString)"
case .processingFailed:
return "Failed to process image"
}
}
}ImagePipeline.swift
import Foundation
import SwiftUI
/// Central image loading orchestrator.
///
/// Flow: Memory Cache → Disk Cache → Download → Process → Store
///
/// Usage:
/// ```swift
/// let pipeline = ImagePipeline.shared
/// let image = try await pipeline.image(for: url)
/// ```
@Observable
final class ImagePipeline: @unchecked Sendable {
static let shared = ImagePipeline()
private let memoryCache: MemoryImageCache
private let diskCache: DiskImageCache
private let downloader: any ImageDownloading
private let processor: ImageProcessor?
init(
memoryCache: MemoryImageCache = MemoryImageCache(),
diskCache: DiskImageCache = DiskImageCache(),
downloader: any ImageDownloading = ImageDownloader(),
processor: ImageProcessor? = nil
) {
self.memoryCache = memoryCache
self.diskCache = diskCache
self.downloader = downloader
self.processor = processor
}
/// Load an image from cache or network.
func image(for url: URL, processing: ImageProcessingOptions? = nil) async throws -> PlatformImage {
let cacheKey = processing.map { "\(url.absoluteString)_\($0.cacheKeySuffix)" } ?? url.absoluteString
let cacheURL = URL(string: cacheKey) ?? url
// 1. Check memory cache
if let cached = await memoryCache.image(for: cacheURL) {
return cached
}
// 2. Check disk cache
if let cached = await diskCache.image(for: cacheURL) {
await memoryCache.store(cached, for: cacheURL)
return cached
}
// 3. Download
let data = try await downloader.download(url: url)
guard var image = PlatformImage(data: data) else {
throw ImageLoadingError.decodingFailed(url: url)
}
// 4. Process (optional)
if let processing, let processor {
image = try processor.process(image, options: processing)
}
// 5. Store in both caches
await memoryCache.store(image, for: cacheURL)
await diskCache.store(image, for: cacheURL)
return image
}
/// Cancel a pending download.
func cancel(for url: URL) async {
await downloader.cancel(url: url)
}
/// Clear all caches.
func clearCache() async {
await memoryCache.removeAll()
await diskCache.removeAll()
}
}
// For testing: allow injection via SwiftUI Environment
private struct ImagePipelineKey: EnvironmentKey {
static let defaultValue: ImagePipeline = .shared
}
extension EnvironmentValues {
var imagePipeline: ImagePipeline {
get { self[ImagePipelineKey.self] }
set { self[ImagePipelineKey.self] = newValue }
}
}ImageProcessor.swift
import Foundation
import CoreGraphics
import ImageIO
/// Image processing options for the pipeline.
enum ImageProcessingOptions: Sendable {
/// Resize to fit within target size, maintaining aspect ratio.
case resize(targetSize: CGSize)
/// Generate a thumbnail of the specified max pixel dimension.
case thumbnail(maxPixelSize: Int)
/// Cache key suffix to differentiate processed variants.
var cacheKeySuffix: String {
switch self {
case .resize(let size):
return "resize_\(Int(size.width))x\(Int(size.height))"
case .thumbnail(let size):
return "thumb_\(size)"
}
}
}
/// Handles image processing (resize, thumbnail generation).
struct ImageProcessor: Sendable {
func process(_ image: PlatformImage, options: ImageProcessingOptions) throws -> PlatformImage {
switch options {
case .resize(let targetSize):
return try resize(image, to: targetSize)
case .thumbnail(let maxPixelSize):
return try generateThumbnail(from: image, maxPixelSize: maxPixelSize)
}
}
// MARK: - Resize
private func resize(_ image: PlatformImage, to targetSize: CGSize) throws -> PlatformImage {
#if canImport(UIKit)
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
#elseif canImport(AppKit)
let newImage = NSImage(size: targetSize)
newImage.lockFocus()
image.draw(
in: NSRect(origin: .zero, size: targetSize),
from: NSRect(origin: .zero, size: image.size),
operation: .copy,
fraction: 1.0
)
newImage.unlockFocus()
return newImage
#endif
}
// MARK: - Thumbnail (ImageIO — most efficient)
private func generateThumbnail(from image: PlatformImage, maxPixelSize: Int) throws -> PlatformImage {
#if canImport(UIKit)
guard let data = image.jpegData(compressionQuality: 0.9) else {
throw ImageLoadingError.processingFailed
}
#elseif canImport(AppKit)
guard let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let data = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else {
throw ImageLoadingError.processingFailed
}
#endif
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true
]
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
throw ImageLoadingError.processingFailed
}
#if canImport(UIKit)
return UIImage(cgImage: cgImage)
#elseif canImport(AppKit)
return NSImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
#endif
}
}CachedAsyncImage.swift
import SwiftUI
/// A drop-in replacement for AsyncImage with caching support.
///
/// Uses `ImagePipeline` for memory/disk caching, request deduplication,
/// and optional image processing.
///
/// Usage:
/// ```swift
/// CachedAsyncImage(url: user.avatarURL) { image in
/// image.resizable().aspectRatio(contentMode: .fill)
/// } placeholder: {
/// ProgressView()
/// }
/// ```
struct CachedAsyncImage<Content: View, Placeholder: View>: View {
let url: URL?
let processing: ImageProcessingOptions?
let content: (Image) -> Content
let placeholder: () -> Placeholder
@Environment(\.imagePipeline) private var pipeline
@State private var loadedImage: PlatformImage?
@State private var isLoading = false
@State private var loadError: Error?
init(
url: URL?,
processing: ImageProcessingOptions? = nil,
@ViewBuilder content: @escaping (Image) -> Content,
@ViewBuilder placeholder: @escaping () -> Placeholder
) {
self.url = url
self.processing = processing
self.content = content
self.placeholder = placeholder
}
var body: some View {
Group {
if let loadedImage {
#if canImport(UIKit)
content(Image(uiImage: loadedImage))
#elseif canImport(AppKit)
content(Image(nsImage: loadedImage))
#endif
} else {
placeholder()
}
}
.task(id: url) {
await loadImage()
}
}
private func loadImage() async {
guard let url else { return }
loadedImage = nil
loadError = nil
isLoading = true
do {
let image = try await pipeline.image(for: url, processing: processing)
guard !Task.isCancelled else { return }
loadedImage = image
} catch {
guard !Task.isCancelled else { return }
loadError = error
}
isLoading = false
}
}
// Convenience initializer without placeholder
extension CachedAsyncImage where Placeholder == ProgressView<EmptyView, EmptyView> {
init(
url: URL?,
processing: ImageProcessingOptions? = nil,
@ViewBuilder content: @escaping (Image) -> Content
) {
self.init(url: url, processing: processing, content: content) {
ProgressView()
}
}
}ImagePrefetcher.swift
import Foundation
/// Prefetches images for upcoming list items.
///
/// Start prefetching when items are about to appear,
/// and cancel when they scroll off screen.
///
/// Usage:
/// ```swift
/// @State private var prefetcher = ImagePrefetcher()
///
/// ForEach(items) { item in
/// ItemRow(item: item)
/// .onAppear { prefetcher.startPrefetching(urls: [item.imageURL]) }
/// .onDisappear { prefetcher.stopPrefetching(urls: [item.imageURL]) }
/// }
/// ```
@Observable
final class ImagePrefetcher {
private var activeTasks: [URL: Task<Void, Never>] = [:]
private let pipeline: ImagePipeline
init(pipeline: ImagePipeline = .shared) {
self.pipeline = pipeline
}
/// Start prefetching images for the given URLs.
func startPrefetching(urls: [URL]) {
for url in urls {
guard activeTasks[url] == nil else { continue }
activeTasks[url] = Task {
_ = try? await pipeline.image(for: url)
}
}
}
/// Cancel prefetching for the given URLs.
func stopPrefetching(urls: [URL]) {
for url in urls {
activeTasks[url]?.cancel()
activeTasks.removeValue(forKey: url)
}
}
/// Cancel all active prefetch tasks.
func stopAll() {
activeTasks.values.forEach { $0.cancel() }
activeTasks.removeAll()
}
}