Home

Why my List scrolled badly, and the four things that fixed it

A list of about 300 rows scrolled smoothly on my phone and visibly stuttered on an iPhone SE. I assumed the answer was “the device is old” for longer than I should have.

It was four separate problems, all of them mine, and none of them the one I expected.

1. Work in body that should not have been there

The row was formatting a date and a currency value, per row, on every evaluation:

struct OrderRow: View {
    let order: Order

    var body: some View {
        HStack {
            Text(order.title)
            Spacer()
            Text(order.total, format: .currency(code: order.currencyCode))
            Text(DateFormatter.medium.string(from: order.date))
        }
    }
}

body runs many times per frame during scrolling — the layout system queries children with several proposals before deciding anything. Formatting is not free, and doing it three hundred times a second is measurable.

The fix is to format once, where the data changes, and hand the view a value that is already a string:

struct OrderRowModel: Identifiable {
    let id: Order.ID
    let title: String
    let total: String        // formatted once, upstream
    let date: String
}

This is the “do not compute in body” rule, and it is the one I break most often because computing in body reads so naturally.

2. An unstable id

List(orders.indices, id: \.self) { index in
    OrderRow(order: orders[index])
}

Index-based identity means that inserting a row at the top changes the identity of every row below it. SwiftUI concludes that three hundred rows all became different rows, throws away their storage, and rebuilds everything — with a crossfade animation on each, which is why the insert looked wrong as well as being slow.

List(orders) { order in           // Order: Identifiable, id from the model
    OrderRow(order: order)
}

Warning

id: \.self on a collection of values is a related trap: it hashes the whole element, so a row whose contents change gets a new identity and is rebuilt rather than updated. Use a stable id from the data, not the data itself.

3. A GeometryReader inside the row

There was one, used to size a progress bar as a fraction of the row’s width. GeometryReader participates in the layout pass and reruns whenever anything about the layout changes, which during scrolling is constantly.

// before
GeometryReader { proxy in
    Rectangle().frame(width: proxy.size.width * order.progress)
}

// after
Rectangle().containerRelativeFrame(.horizontal) { width, _ in width * order.progress }

Same visual result, no participation in the sizing pass. This was the single largest improvement of the four.

4. Images decoded at full resolution

Each row had a 40×40 thumbnail, loaded from a 2000×2000 source image. Image holds the decoded bitmap, not the file — so a 2000×2000 image is about 16MB in memory regardless of how small it is drawn.

Thirty visible rows meant hundreds of megabytes of decoded bitmaps, constant memory pressure, and decode work happening during scrolling.

func thumbnail(from data: Data, size: CGFloat) -> UIImage? {
    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: size * UIScreen.main.scale,
        kCGImageSourceShouldCacheImmediately: true,
    ]
    guard let source = CGImageSourceCreateWithData(data as CFData, nil),
          let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
    else { return nil }
    return UIImage(cgImage: image)
}

CGImageSourceCreateThumbnailAtIndex decodes directly at the target size rather than decoding the full image and scaling it down. kCGImageSourceShouldCacheImmediately moves the decode to where you call it — off the main thread — rather than lazily at draw time.

What I checked to find them

Self._printChanges() in the row, scrolled, and watched the console. Rows printing @identity during a plain scroll was the tell for problem 2.

Instruments’ Animation Hitches template for the rest. It shows frame commit times against the deadline and marks the frames that missed. Expanding a hitch shows what was on the main thread during it, which is how the GeometryReader and the image decoding turned up.

The order I would check next time

  1. Is the id stable? Cheapest to check, most dramatic when wrong.
  2. Is there real work in body? Formatting, sorting, filtering, date arithmetic.
  3. Is there a GeometryReader in the row? Almost always replaceable now.
  4. Are images being decoded larger than they are drawn? Nearly universal, and the memory consequences are worse than the CPU ones.

What I did not need in the end: pagination, a diffable data source, dropping to UIKit, or any of the other things I had lined up before measuring. The list was fine — it was doing four unnecessary things per row, three hundred times a second.