Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Glimpse

A minimal alternative to Kingfisher and Nuke. A SwiftUI-native async image loader with memory + disk caching, downsampling, request coalescing and cancel-on-disappear — in ~1,000 lines with zero dependencies.

CI Swift 6 Platforms Dependencies License

GlimpseImage(url: post.imageURL) { phase in
    switch phase {
    case .empty:            ProgressView()
    case .success(let img): img.resizable().scaledToFill()
    case .failure:          Image(systemName: "photo")
    @unknown default:       EmptyView()
    }
}

Why

SwiftUI's AsyncImage has no decoded-image cache, no request coalescing, and no control over decode size — it decodes every image at full resolution, so a list of 3000×2000 photos costs ~24 MB of RAM each. Kingfisher and Nuke solve all of that, and a great deal more besides: image processors, animated GIF, progressive JPEG, UIKit view extensions, custom transports.

Glimpse is the middle ground. It does the four things that actually make image loading feel fast, and stops:

  1. Caches decoded images in memory, keyed by URL and display size.
  2. Caches original bytes on disk, so a cold launch doesn't re-download.
  3. Downsamples at decode time to the size the view actually occupies.
  4. Coalesces duplicate requests and cancels loads when views disappear.

It's a drop-in replacement for AsyncImage — same initializers, same AsyncImagePhase — so adopting it is a rename.

Glimpse AsyncImage Kingfisher / Nuke
Decoded-image memory cache
Disk cache ✅ own store, size + TTL budget HTTP URLCache only, server-header dependent
Downsampling to view size
Request coalescing
Prefetching
Cancel on disappear
Animated GIF / APNG
Progressive JPEG
Image processors / filters
UIKit / AppKit view support
Dependencies 0 0
Source size 681 lines of code across 11 files Substantially larger

If you need the ❌ rows, use Nuke or Kingfisher. They're excellent, and Glimpse is not trying to replace them — it's trying to be the smaller thing you reach for when you don't need them.

Install

// Package.swift
dependencies: [
    .package(url: "https://github.com/dayaki/Glimpse.git", from: "1.0.0")
]

Or in Xcode: File → Add Package Dependencies… and paste the URL.

import Glimpse

Usage

The simple case

GlimpseImage(url: url)

GlimpseImage(url: url)
    .placeholder { Color.gray.opacity(0.2) }

Content and placeholder

The same shape as AsyncImage:

GlimpseImage(url: url) { image in
    image.resizable().scaledToFill()
} placeholder: {
    ProgressView()
}
.frame(width: 64, height: 64)
.clipShape(.rect(cornerRadius: 8))

Every phase

GlimpseImage(url: url, transaction: Transaction(animation: .easeIn(duration: 0.2))) { phase in
    switch phase {
    case .empty:            ShimmerView()
    case .success(let img): img.resizable().scaledToFill()
    case .failure(let err): ErrorView(error: err)
    @unknown default:       EmptyView()
    }
}

Decode size

By default Glimpse measures the view and decodes to fit it. You can be explicit, or opt out:

// Decode to a known size (in points; multiplied by the display scale internally).
GlimpseImage(url: url)
    .glimpseDownsampling(.fixed(CGSize(width: 64, height: 64)))

// Decode at natural size, like AsyncImage.
GlimpseImage(url: url)
    .glimpseDownsampling(.none)

// Set it once for a whole screen.
MyFeedView()
    .glimpseDownsampling(.automatic)

Automatic sizing works best when the image's size doesn't depend on the image — a .frame, a grid cell, a list row. If the layout is entirely image-driven, Glimpse measures the placeholder first and re-decodes (from cache, not the network) once layout settles.

Prefetching

struct Feed: View {
    let posts: [Post]

    var body: some View {
        List(posts) { post in
            Row(post: post)
                .task {
                    // Warm the next few rows while this one is on screen.
                    let upcoming = posts.next(3, after: post).map(\.imageURL)
                    await ImagePipeline.shared.prefetch(upcoming)
                }
        }
    }
}

cancelPrefetch(_:) stops prefetches that scrolled out of range. It's safe to call for a URL a visible view is also loading — the shared download only dies once nothing is waiting on it.

Configuration

let pipeline = ImagePipeline(
    configuration: GlimpseConfiguration(
        memoryLimit: 64 * 1024 * 1024,   // decoded images
        diskLimit: 250 * 1024 * 1024,    // original bytes
        ttl: 60 * 60 * 24 * 30           // 30 days
    )
)

WindowGroup {
    ContentView()
        .glimpsePipeline(pipeline)
}

Defaults: memory min(150 MB, ¼ of physical RAM), disk 150 MB, TTL 7 days, cache directory Library/Caches/<bundle-id>.glimpse.

Authenticated images

var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

let image = try await ImagePipeline.shared.image(
    for: ImageRequest(urlRequest: request, targetSize: CGSize(width: 200, height: 200))
)

Cache management

await ImagePipeline.shared.removeAll()            // both layers
await ImagePipeline.shared.removeAllFromMemory()  // keep the bytes on disk

Testing your own views

The pipeline takes a URLSessionConfiguration, so you can stub the network without Glimpse exposing a transport protocol:

let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MyStubProtocol.self]

let pipeline = ImagePipeline(
    configuration: GlimpseConfiguration(
        diskCacheDirectory: temporaryDirectory,
        urlSessionConfiguration: configuration
    )
)

MyView().glimpsePipeline(pipeline)

How it works

GlimpseImage (@MainActor)
  │  .task(id:) — starts on appear, cancels on disappear
  ▼
ImagePipeline (actor)
  ├─ 1. data(for:)   memory-miss → DiskCache → URLSession    keyed by URL
  └─ 2. image(for:)  MemoryCache → ImageDecoder.downsample   keyed by URL + pixel size + scale

Two stages, two keys. Bytes are cached by URL alone; decoded images by URL and size. So one download backs every size an image is ever shown at, and changing the display size re-decodes without re-downloading.

The size/fetch race. A view doesn't know its pixel size until the first layout pass, but the download doesn't care about size. So Glimpse starts fetching immediately and decodes once the bytes and the size are both in hand. Re-decodes only ever fire after a successful load, which means layout settling can never cancel an in-flight download.

Coalescing with refcounts. Duplicate requests share one Task, which carries a subscriber count. One view scrolling away decrements it; the download is only cancelled when it reaches zero. Five cells asking for the same avatar produce one request, and four of them going offscreen doesn't strand the fifth.

Downsampling. CGImageSourceCreateThumbnailAtIndex with ShouldCacheImmediately, so the decode happens off the main thread instead of lazily at draw time. Target sizes are quantized to 64px buckets so 199px and 201px slots share a cache entry, and Glimpse never upscales past an image's natural size.

Disk cache. No index file: SHA256 of the URL is the filename, modification date doubles as the LRU timestamp, and fileSize feeds the budget — the filesystem is the only state. Sweeps drop expired files, then delete oldest-first down to 80% of the budget so writes don't trigger a sweep every time. Reads only touch the timestamp if it's over an hour stale, so browsing a warm cache doesn't cause a write per image.

Concurrency. Everything is Swift 6 strict-concurrency clean in the .v6 language mode. Disk I/O and image decoding run in nonisolated async functions, which execute on the global executor — the pipeline actor is held for bookkeeping only, never for the length of a read or a decode. URLCache is disabled on the session, since caching every byte twice would be wasteful.

Verifying the claims

Nothing in this README is a number you have to take on faith:

swift build                          # warning-free under Swift 6 strict concurrency
swift test                           # 36 tests, including coalescing + cancellation semantics
xcodebuild -scheme Glimpse -destination 'generic/platform=iOS Simulator' build

# The size claim
find Sources -name '*.swift' -exec cat {} + | grep -vE '^\s*(//|$)' | wc -l

The tests assert the behaviour the design is sold on, not just the happy path: that eight concurrent requests produce exactly one network call, that cancelling one subscriber leaves the download alive while cancelling all of them kills it, that changing decode size re-decodes without re-downloading, that a cold pipeline reuses bytes from disk, and that HTTP failures aren't cached.

For memory, measure it in your own app — Instruments → Allocations, the same list twice, once with AsyncImage and once with GlimpseImage. The saving is a function of how much bigger your source images are than their slots, so a synthetic number here would tell you nothing useful.

What Glimpse deliberately doesn't do

Honest limitations, not a roadmap:

  • No animated GIF / APNG or progressive JPEG. Static images only.
  • No image processors. No blur, rounding, or filter pipeline — use SwiftUI modifiers.
  • No UIKit / AppKit views. DecodedImage.platformImage gives you a UIImage/NSImage if you need to bridge, but there's no imageView.setImage(url:).
  • No retries or negative caching. A failure is reported and not remembered.
  • No HTTP revalidation. Freshness is Glimpse's TTL, not ETag/Cache-Control. Cache-bust with a versioned URL if your images mutate in place.
  • Prefetch politeness is priority-based. Prefetches run at .utility but share the connection pool, so a very large prefetch batch can still compete with visible loads.

Requirements

iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+ · Swift 6.0+ / Xcode 16+

License

MIT — see LICENSE.

About

A minimal SwiftUI async image loader: memory + disk caching, downsampling, request coalescing and cancel-on-disappear. Zero dependencies, ~700 lines of code. A tiny alternative to Kingfisher and Nuke.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages