A masonry layout arranges variable-height items in fixed-width columns, packing each new item into the currently-shortest column (Pinterest-style). SwiftUI has no built-in masonry, and the obvious implementations collapse under scale. This plan picks the right approach for your item count and lays out the build order.
How many items can the feed hold at once?
├─ Bounded (<~300, e.g. a profile grid)
│ → Approach A: Layout protocol
│ + Cleanest code, true greedy packing
│ − Measures EVERY child on every invalidation (no virtualization)
│
└─ Unbounded (infinite scroll, 1k–100k items)
→ Approach B: Multi-column LazyVStack with pre-computed distribution
+ Virtualized per column (only visible rows materialize)
+ 120Hz-safe on ProMotion
− Requires known/estimated item aspect ratio before render
Default recommendation: Approach B for anything image-heavy or paginated. Only use Approach A when the full set is small and already in memory.
MasonryLayout: Layoutstruct with:columns: Intandspacing: CGFloatparameterssizeThatFits: walk subviews, callsizeThatFits(.init(width: columnWidth, height: nil))on each, drop each into the shortest column, return tallest column heightplaceSubviews: same walk, but callplace(at:anchor:proposal:)at the computed (x, y)cache: store[columnHeights]+[placements]to avoid recomputation on unchanged input
- Wrap usage:
ScrollView { MasonryLayout(columns: 2, spacing: 8) { ForEach(items) { item in MasonryCell(item: item) } } }
- Cache is mandatory. SwiftUI calls
sizeThatFits+placeSubviewsrepeatedly; without a cache each call is O(n) measurements. - Use
onGeometryChange(notGeometryReader) if column count must adapt to width. - Items should be
Equatableso SwiftUI can skip re-measuring unchanged children.
Layout has no viewport awareness — it must know every child's size. 5,000 cells means 5,000 sizeThatFits calls per pass. Scrolling stays smooth (measurement is cached), but initial load and any invalidation spike CPU.
Each column is its own LazyVStack, so only visible rows are instantiated. The trade-off: you must decide which column each item belongs to before you know its rendered height.
You need one of:
- Server-provided
width/heightfor images (Pinterest, Unsplash API) - Aspect-ratio hint in the data model
- Fixed height-per-type (card, video, quote)
If heights are truly unknown until render, use Approach A or precompute heights in a first pass.
-
MasonryDistributor— pure function that assigns items to columnsfunc distribute(_ items: [Item], columns: Int, columnWidth: CGFloat) -> [[Item]]
- Keep a running
[Double]of column heights - For each item: compute rendered height =
columnWidth * (item.height / item.width) - Append to
argmin(columnHeights); update that column's running height - Return
[[Item]](one array per column) - Unit-test this. It's pure logic.
- Keep a running
-
MasonryViewScrollView { HStack(alignment: .top, spacing: spacing) { ForEach(columns.indices, id: \.self) { i in LazyVStack(spacing: spacing) { ForEach(columns[i]) { item in MasonryCell(item: item, width: columnWidth) } } } } .padding(.horizontal, spacing) }
-
Column-count adaptation with
onGeometryChange:.onGeometryChange(for: Int.self) { proxy in max(2, Int(proxy.size.width / targetColumnWidth)) } action: { newCount in columnCount = newCount columns = distribute(items, columns: newCount, columnWidth: ...) }
-
Pagination hook — when near-bottom of any column, call the loader; redistribute only the newly-appended tail, not the whole feed.
- Re-distribute only when the item array or column count changes, not on every render. Store
columnsin@State, recompute in.onChange(of: items)/.onChange(of: columnCount). - Virtualized cells only. Each cell should carry a fixed
height(from aspect ratio) via.frame(height:)so LazyVStack can estimate scroll offsets without materializing off-screen rows. - AsyncImage prefetching. Use a prefetch window (±500pt) on scroll position. Without it, 120Hz scrolling will outrun the image loader.
- Equatable cells +
.equatable()on expensive subviews to prevent redundant recomputation when unrelated state changes. .drawingGroup()on cells with 5+ overlapping effects (shadows, gradients, blurs). Rasterizes to Metal texture; massive win during scroll.- Do not nest
GeometryReaderinside cells. UseonGeometryChangeat the container level if width is needed.
LazyVStackinside nestedScrollViews didn't properly lazy-load before iOS 26. Fine on iOS 26+; if you support older, flatten the hierarchy.- Column imbalance grows over many pages. Periodically re-balance by inserting a "spacer" in the shorter column if the delta exceeds ~1.5× average item height.
animation(.default, value: columns)during re-distribution causes all cells to re-animate. Scope animations to individual cells instead.
- Data model — confirm every item can produce
CGSize(width:height:)before render. If not, fall back to Approach A. - Pure distributor function + unit tests — exercise edge cases: 0 items, 1 column, items larger than any column's current height, identical items.
- Static
MasonryViewwith hardcoded 2 columns — verify layout visually with a known dataset. No scrolling, no pagination yet. - Wire
ScrollView+LazyVStackper column — confirm virtualization via Instruments (allocations should stay flat while scrolling). - Adaptive column count via
onGeometryChange— test at 320pt, 768pt, 1024pt, 1366pt widths. Test Split View on iPad. - Pagination + prefetch — append-only redistribution; image prefetch window.
- Polish — pull-to-refresh, scroll-to-top, empty/error states.
- Profile with Instruments 26 — use the new SwiftUI instrument. Look for orange/red update bars; verify Cause & Effect graph shows only the affected column re-rendering on append.
- Scroll a 10,000-item feed at 120Hz on a ProMotion device — no dropped frames
- Memory stays flat (±20MB) during scroll, does not grow with distance scrolled
- Column count adapts correctly in iPad Split View (33 / 50 / 66%)
- Rotating device does not reshuffle already-viewed items (stable sort by index within column)
- Distributor unit tests cover empty, single-column, and extreme-aspect-ratio cases
- No
GeometryReaderused (search:rg "GeometryReader") - No
UIScreen.main.boundsused (search:rg "UIScreen")
| Tempting shortcut | Why it breaks |
|---|---|
LazyVGrid(columns: [.adaptive(minimum:)]) |
This is a grid, not masonry — all rows align to the tallest cell, wasting space |
Single LazyVStack with HStack rows of 2 items |
Forces pairing; one short item + one tall item creates ragged bottoms but still wastes the matched row height |
Measuring each cell with GeometryReader then re-laying out |
Triggers infinite layout passes; 60fps floor |
Recomputing distribution in body |
Runs every frame; distribution must live in @State |
AsyncImage with no placeholder size |
Causes cell height to change after load, which breaks LazyVStack's scroll offset estimation |
Apple APIs: Layout protocol, LazyVStack, ScrollView, onGeometryChange
WWDC: 2022-10056 (Compose custom layouts with SwiftUI), 2025-208 (Swift 6.2 layout APIs), 2025-SwiftUI-Instrument
Related Axiom skills: axiom-swiftui (skills/layout.md), axiom-swiftui (skills/swiftui-performance.md)