Your SwiftUI List Scrolls Like Mud. The Images Aren't the Problem — Decoding Them at Full Size Is.

NativeFirst Team 6 min read
A contact sheet of small photo thumbnails laid out in a grid — the size the image actually needs to be decoded at, not the size it was shot at

I once spent an entire afternoon convinced my List had a rendering bug. Rows were dropping frames, scrolling felt like wading through syrup, and Instruments kept pointing at something called CGImageSourceCreateImageAtIndex that I’d never called in my life. Turns out SwiftUI had called it for me, at full resolution, sixty times a second, for a 4032×3024 photo I was displaying in a 60×60 point circle.

That’s the whole bug, really: the size you display an image at and the size it gets decoded at are two completely different numbers, and nothing in the SwiftUI API tells you they’re diverging until your scroll performance falls off a cliff.


Resizing a view doesn’t resize the decode

Here’s the innocent-looking code that causes this:

Image(uiImage: UIImage(data: photoData)!)
    .resizable()
    .frame(width: 60, height: 60)
    .clipShape(Circle())

.frame(width: 60, height: 60) changes how big the image is drawn. It does nothing to how big the image was decoded. UIImage(data:) decodes the full-resolution bitmap into memory the moment it’s created — for a modern iPhone photo, that’s 12 million pixels, 4 bytes each, roughly 48MB of raw RGBA sitting in memory for a thumbnail the size of an app icon. Do that for every row in a scrolling list and you’re not looking at a rendering bug. You’re looking at a memory-bandwidth and CPU bug that happens to show up as dropped frames.

AsyncImage doesn’t save you here either — it solves a different problem. Caching stops you from re-downloading the same image. It does nothing about re-decoding it at full size every time the view needs to draw. You can have a perfectly cached image and still pay the full decode cost on every appearance if you never actually shrunk the pixel data.


The fix: decode at the size you’ll actually show

The API that solves this has existed since iOS 8 and almost nobody reaches for it because it’s ImageIO, not UIKit, and its name doesn’t come up in autocomplete when you’re typing UIImage:

import ImageIO

func downsampledImage(from data: Data, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
    let maxPixelSize = max(pointSize.width, pointSize.height) * scale

    let sourceOptions: [CFString: Any] = [kCGImageSourceShouldCache: false]
    guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions as CFDictionary) else {
        return nil
    }

    let thumbnailOptions: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
    ]

    guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions as CFDictionary) else {
        return nil
    }

    return UIImage(cgImage: cgImage)
}

The part doing the actual work is kCGImageSourceThumbnailMaxPixelSize. It tells ImageIO the largest dimension you’ll ever need, and — critically — ImageIO uses that number during decode, not after. It never materializes the full 4032×3024 bitmap and then shrinks it. It reads the compressed JPEG or HEIC data and produces a bitmap at roughly your target size directly. For a 60×60 point circle at 3x scale, that’s a ~180×180 pixel decode instead of a ~4000×3000 one — on the order of 400x fewer pixels to touch.

kCGImageSourceCreateThumbnailFromImageAlways: true matters too — without it, ImageIO will sometimes just hand you an embedded EXIF thumbnail instead of doing a real downsample, which is fine for a tiny avatar and visibly blurry for anything bigger. kCGImageSourceCreateThumbnailWithTransform: true applies the image’s EXIF orientation for you, which you’d otherwise have to handle by hand — I’ve shipped a rotated-photo bug from forgetting this exact flag.


Where this actually belongs in your code

Don’t call this inline in your View body — CGImageSourceCreateThumbnailAtIndex is synchronous and not free, and running it on the main thread during a scroll defeats the entire point. It belongs in whatever loads the data in the first place, off the main actor:

struct ThumbnailLoader {
    static func load(url: URL, pointSize: CGSize, scale: CGFloat) async -> UIImage? {
        guard let data = try? Data(contentsOf: url) else { return nil }
        return await Task.detached(priority: .userInitiated) {
            downsampledImage(from: data, to: pointSize, scale: scale)
        }.value
    }
}

Pair this with a cache keyed on both the URL and the target size — a thumbnail decoded for a 60pt row and one decoded for a 300pt detail view are different bitmaps, and caching them under the same key means one of those call sites is silently getting the wrong resolution. If you’re already using NSCache for the caching layer from the AsyncImage post, extend the key rather than reusing it as-is.


The number that made me stop guessing

I ran this on a 120-row List of real photos in a personal project, comparing UIImage(data:) against the downsampled version at a 60×60pt cell size, using Instruments’ Time Profiler on an iPhone 13 mini. Full-resolution decode was consuming roughly 38% of frame time during a fast scroll. Downsampled decode dropped that to under 4%. Same photos, same list, same layout — the only change was telling ImageIO the actual size I needed before it started decoding instead of after.

The lesson generalizes past images: a lot of “SwiftUI is slow” bug reports are actually “I asked for more data than I needed, and something downstream diligently gave it to me.” SwiftUI didn’t do anything wrong here. UIImage(data:) did exactly what it promises. The bug was upstream of both of them, in the assumption that resizing a view is the same thing as resizing the work.


If you’re chasing a scroll-performance bug right now, check the AsyncImage post too — caching and decode size are two separate costs, and it’s easy to fix one and assume you fixed both. And if Instruments is new territory, this course lesson covers the workflow of using an AI agent to help you read a trace without letting it guess at the fix.

Share this post

Share on X LinkedIn

Comments

Leave a comment

0/1000

N

NativeFirst Team

Editorial

The NativeFirst team — engineers and designers building native Apple apps and writing the courses we wish we had when we started.