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
- Is the id stable? Cheapest to check, most dramatic when wrong.
- Is there real work in
body? Formatting, sorting, filtering, date arithmetic. - Is there a
GeometryReaderin the row? Almost always replaceable now. - 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.