Skip to content

Instantly share code, notes, and snippets.

@CharlesWiltgen
Created April 16, 2026 17:00
Show Gist options
  • Select an option

  • Save CharlesWiltgen/69f7c74ed8e4331485f2cc27a74f8b32 to your computer and use it in GitHub Desktop.

Select an option

Save CharlesWiltgen/69f7c74ed8e4331485f2cc27a74f8b32 to your computer and use it in GitHub Desktop.
Performant Masonry Layout in SwiftUI — Build Plan

Performant Masonry Layout in SwiftUI — Build Plan

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.

Decision: which approach fits your data?

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.


Approach A — Layout protocol (iOS 16+, bounded sets)

What to build

  1. MasonryLayout: Layout struct with:
    • columns: Int and spacing: CGFloat parameters
    • sizeThatFits: walk subviews, call sizeThatFits(.init(width: columnWidth, height: nil)) on each, drop each into the shortest column, return tallest column height
    • placeSubviews: same walk, but call place(at:anchor:proposal:) at the computed (x, y)
    • cache: store [columnHeights] + [placements] to avoid recomputation on unchanged input
  2. Wrap usage:
    ScrollView {
        MasonryLayout(columns: 2, spacing: 8) {
            ForEach(items) { item in MasonryCell(item: item) }
        }
    }

Performance guardrails

  • Cache is mandatory. SwiftUI calls sizeThatFits + placeSubviews repeatedly; without a cache each call is O(n) measurements.
  • Use onGeometryChange (not GeometryReader) if column count must adapt to width.
  • Items should be Equatable so SwiftUI can skip re-measuring unchanged children.

Why it fails at scale

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.


Approach B — Multi-column LazyVStack (recommended default)

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.

Prerequisite: predictable item height

You need one of:

  • Server-provided width/height for 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.

What to build

  1. MasonryDistributor — pure function that assigns items to columns

    func 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.
  2. MasonryView

    ScrollView {
        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)
    }
  3. 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: ...)
    }
  4. Pagination hook — when near-bottom of any column, call the loader; redistribute only the newly-appended tail, not the whole feed.

Performance guardrails

  • Re-distribute only when the item array or column count changes, not on every render. Store columns in @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 GeometryReader inside cells. Use onGeometryChange at the container level if width is needed.

Known sharp edges

  • LazyVStack inside nested ScrollViews 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.

Build order

  1. Data model — confirm every item can produce CGSize(width:height:) before render. If not, fall back to Approach A.
  2. Pure distributor function + unit tests — exercise edge cases: 0 items, 1 column, items larger than any column's current height, identical items.
  3. Static MasonryView with hardcoded 2 columns — verify layout visually with a known dataset. No scrolling, no pagination yet.
  4. Wire ScrollView + LazyVStack per column — confirm virtualization via Instruments (allocations should stay flat while scrolling).
  5. Adaptive column count via onGeometryChange — test at 320pt, 768pt, 1024pt, 1366pt widths. Test Split View on iPad.
  6. Pagination + prefetch — append-only redistribution; image prefetch window.
  7. Polish — pull-to-refresh, scroll-to-top, empty/error states.
  8. 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.

Verification checklist

  • 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 GeometryReader used (search: rg "GeometryReader")
  • No UIScreen.main.bounds used (search: rg "UIScreen")

Anti-patterns to avoid

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

Resources

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment