English | 日本語
Displays remote images in SwiftUI when your app does its own fetching — behind your own API and authentication — and handles the memory and disk caching, retries and loading states for you.
Your app supplies the way images are fetched. This package owns the caching.
Authentication, endpoints and response shapes differ per app — the package has no business guessing them. Caching, retries and the SwiftUI loading lifecycle are the same everywhere — you should not have to write them again.
So you implement three methods:
public protocol ImageTransport: Sendable {
func fetch(id: String) async throws -> Data
func upload(_ data: Data, contentType: String) async throws -> String // → image id
func delete(id: String) async throws
}…and ImageLibrary gives you a two-layer cache keyed by image id, retries, prefetching,
a synchronous disk read for widgets, and the CachedRemoteImage view.
- ✅ Works with private storage — an authenticated API returning raw bytes is the default case, not the exception
- ✅ Zero dependencies — nothing enters your resolution space, so the package cannot collide with your HTTP client's generation
- ✅ Memory & disk cache keyed by image id — decoded images in memory, the bytes you received on disk
- ✅ Widget-ready — App Group storage plus a synchronous, network-free disk read
- ✅ Failures reach the caller — nothing is swallowed into a
printor a silentnil - ✅ Customizable retry policy — fixed count or exponential backoff
- ✅ Customizable placeholder & error views — fully replaceable UI
No default transport is bundled, deliberately: shipping one would constrain the dependency
resolution of everybody who never uses it, because SPM resolves dependencies per package and not
per target. Written straight on URLSession, a transport is about 40 lines; on an HTTP client
your app already has, about 15.
Three moving parts: the transport you write, one library, and the view.
import CachedRemoteImage
struct AppImages: ImageTransport {
let api: MyAPIClient
func fetch(id: String) async throws -> Data { try await api.imageBytes(id) }
func upload(_ bytes: Data, contentType: String) async throws -> String { try await api.putImage(bytes, contentType).id }
func delete(id: String) async throws { try await api.dropImage(id) }
}
let library = try ImageLibrary(transport: AppImages(api: api)) // once, at launch
ContentView().imageLibrary(library) // once, at the root
CachedRemoteImage(source: .imageId("img_12345")) // anywhere below itThat is the whole surface for the common case: the view resolves the library from the environment, checks memory, then disk, and only then calls your transport.
ImageLibrary.init throws when the cache directory cannot be resolved — a missing App Group
entitlement, for instance. That is a configuration mistake, and falling back to some other
directory would leave your widget permanently blank with no way to find out why.
Two other sources skip the transport entirely, for images that need no authentication:
CachedRemoteImage(source: .url(url))
CachedRemoteImage(source: .urlString("https://example.com/a.jpg"))If your metadata API hands back a public URL, the transport becomes two steps — read the
metadata, then fetch the bytes from that URL. ImageTransport returns Data either way, so
everything from ImageLibrary onwards behaves identically.
func fetch(id: String) async throws -> Data {
let metadata = try await api.imageMetadata(id)
return try await URLSession.shared.data(from: metadata.url).0
}If resolving the id into a URL on every fetch is too expensive, cache that inside your transport.
The image bytes are already cached by ImageLibrary.
A widget extension cannot fetch images: WidgetKit's timeline generation has no place for an async network call, and giving an extension your auth credentials is the wrong shape anyway.
So the app prefetches, and the widget reads the disk synchronously.
// App — point the cache at an App Group, prefetch after syncing
let library = try ImageLibrary(transport: AppImages(api: api),
configuration: .appGroup("group.com.example.app"))
let failed = await library.prefetch(topItems.map(\.imageId))
// Widget — same location, no transport, no network
let cache = try ImageDiskCache(location: .appGroup("group.com.example.app"))
let bytes = cache.imageData(for: item.imageId) // nil = not on the device yetImageDiskCache has no way to reach the network, so a widget holding one cannot stall on a
fetch. Both sides resolve the directory from the same ImageCacheLocation value, so the paths
cannot drift apart — a one-character difference would otherwise leave the widget silently blank.
Configuration is per library, not per view — the caches are one shared resource, and a view is not the thing that owns them. Presets cover most of it:
let library = try ImageLibrary(transport: transport, configuration: .withRetry).standard is the Caches directory with no retries, .withRetry adds exponential backoff, and
.appGroup(_:subdirectory:) moves the files where a widget can read them. Everything is
adjustable through ImageLibraryConfiguration.
- Memory holds decoded images. What it saves during a scroll is decoding, not file reads
- Disk holds the bytes exactly as received — no re-encoding, so PNG transparency survives and a widget reads the real thing. Entries past the size limit are dropped oldest-first (App Group storage is not purged by the OS, so the limit has to be yours)
Everything that can go wrong reaches the error view as an ImageLoadError: a missing library, a
string that is not a URL, a failing transport, or bytes that do not decode. The cause travels as
text — keeping the original error type would cost Sendable and Equatable, and the code that
wants to branch on the type (refresh a token, sign the user out) is the code that wrote the
transport and threw it.
Write paths (add, remove) rethrow whatever your transport threw, untouched.
📚 API reference and guides
— a full transport written on URLSession, the widget setup end to end, and every configuration
option.
dependencies: [
.package(url: "https://github.com/no-problem-dev/swift-cached-remote-image.git", from: "5.0.0")
]There is one product to add to your target:
.product(name: "CachedRemoteImage", package: "swift-cached-remote-image"),4.0.0 does not resolve and from: "4.0.2" skips it. If a stale Package.resolved still pins
4.0.0, delete it and resolve again.
- iOS 17.0+
- macOS 14.0+
- Swift 6.0+
This project is released under the MIT License. See the LICENSE file for details.