Skip to content

Instantly share code, notes, and snippets.

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

  • Save CharlesWiltgen/42c0489749e3e10c34b32260a9c68f7b to your computer and use it in GitHub Desktop.

Select an option

Save CharlesWiltgen/42c0489749e3e10c34b32260a9c68f7b to your computer and use it in GitHub Desktop.
Performant Masonry Layout in SwiftUI — Implementation (code + validation)

Performant Masonry Layout in SwiftUI — Implementation

Companion to the build plan. This document contains working Swift code, file-level organization, and the non-obvious engineering details that determine whether your masonry scrolls at 120Hz or stutters.

Targets iOS 17+. All samples compile under Swift 6.1 with strict concurrency checking.


File structure

Masonry/
├── Model/
│   ├── MasonryItem.swift            // Protocol + sample conformance
│   └── MasonryDistributor.swift     // Pure distribution function
├── Layout/
│   ├── MasonryLayout.swift          // Approach A (Layout protocol)
│   └── MasonryView.swift            // Approach B (multi-column LazyVStack)
├── Cells/
│   ├── MasonryCell.swift            // Generic cell with aspect-ratio frame
│   └── AsyncMasonryImage.swift      // Prefetching image loader
├── Prefetch/
│   └── ImagePrefetcher.swift        // Off-screen image warming
└── Tests/
    └── MasonryDistributorTests.swift

1. Item protocol

Cells need a predictable aspect ratio before images load, otherwise LazyVStack cannot estimate scroll offsets.

// Model/MasonryItem.swift
import Foundation

protocol MasonryItem: Identifiable, Equatable, Sendable {
    /// Intrinsic width:height of the content (e.g., from server metadata).
    /// Used to reserve space before any async loading completes.
    var aspectRatio: CGFloat { get }
}

extension MasonryItem {
    /// Height the cell will occupy at a given column width.
    func renderedHeight(for columnWidth: CGFloat) -> CGFloat {
        guard aspectRatio > 0 else { return columnWidth } // 1:1 fallback
        return columnWidth / aspectRatio
    }
}

// Example model (Pinterest-style photo feed):
struct Photo: MasonryItem {
    let id: UUID
    let url: URL
    let width: Int
    let height: Int

    var aspectRatio: CGFloat {
        guard height > 0 else { return 1 }
        return CGFloat(width) / CGFloat(height)
    }
}

Why Equatable matters: SwiftUI uses structural equality to decide whether a view needs re-rendering. Without it, every cell re-evaluates on every parent update.


2. Pure distributor (the heart of Approach B)

Greedy bin-packing: each item lands in whichever column is currently shortest.

// Model/MasonryDistributor.swift
import Foundation

enum MasonryDistributor {
    /// Distribute items into N columns using shortest-column-first packing.
    ///
    /// - Parameters:
    ///   - items: Items to distribute, in display order.
    ///   - columnCount: Number of columns (≥ 1).
    ///   - columnWidth: Width each column will render at.
    ///   - spacing: Vertical spacing between items in a column.
    /// - Returns: Array of columns; each column is an array of items.
    static func distribute<T: MasonryItem>(
        _ items: [T],
        columnCount: Int,
        columnWidth: CGFloat,
        spacing: CGFloat = 0
    ) -> [[T]] {
        precondition(columnCount >= 1, "columnCount must be >= 1")
        guard columnCount > 0 else { return [] }

        var columns: [[T]] = Array(repeating: [], count: columnCount)
        var heights: [CGFloat] = Array(repeating: 0, count: columnCount)

        for item in items {
            // argmin — which column is currently shortest?
            var shortestIndex = 0
            var shortestHeight = heights[0]
            for i in 1..<columnCount where heights[i] < shortestHeight {
                shortestIndex = i
                shortestHeight = heights[i]
            }

            columns[shortestIndex].append(item)
            let contribution = item.renderedHeight(for: columnWidth) + spacing
            heights[shortestIndex] += contribution
        }

        return columns
    }

    /// Incremental append — distribute only new items given existing column state.
    /// Use for pagination to avoid O(n) re-distribution on every page.
    static func appending<T: MasonryItem>(
        newItems: [T],
        toExisting columns: [[T]],
        columnWidth: CGFloat,
        spacing: CGFloat = 0
    ) -> [[T]] {
        var columns = columns
        var heights = columns.map { column in
            column.reduce(0) { $0 + $1.renderedHeight(for: columnWidth) + spacing }
        }

        for item in newItems {
            var shortestIndex = 0
            for i in 1..<columns.count where heights[i] < heights[shortestIndex] {
                shortestIndex = i
            }
            columns[shortestIndex].append(item)
            heights[shortestIndex] += item.renderedHeight(for: columnWidth) + spacing
        }

        return columns
    }
}

Tests

// Tests/MasonryDistributorTests.swift
import Testing
@testable import Masonry

@Suite("MasonryDistributor")
struct MasonryDistributorTests {

    struct FakeItem: MasonryItem {
        let id: Int
        let aspectRatio: CGFloat
    }

    @Test func emptyInputProducesEmptyColumns() {
        let result = MasonryDistributor.distribute(
            [FakeItem](), columnCount: 3, columnWidth: 100
        )
        #expect(result == [[], [], []])
    }

    @Test func identicalItemsDistributeRoundRobin() {
        let items = (0..<6).map { FakeItem(id: $0, aspectRatio: 1) }
        let result = MasonryDistributor.distribute(
            items, columnCount: 3, columnWidth: 100
        )
        #expect(result.map(\.count) == [2, 2, 2])
    }

    @Test func tallItemGoesToEmptyColumn() {
        let items = [
            FakeItem(id: 0, aspectRatio: 0.5),  // height 200 at width 100
            FakeItem(id: 1, aspectRatio: 1),    // height 100
            FakeItem(id: 2, aspectRatio: 1),    // height 100 — should go to col 1
        ]
        let result = MasonryDistributor.distribute(
            items, columnCount: 2, columnWidth: 100
        )
        #expect(result[0].map(\.id) == [0])
        #expect(result[1].map(\.id) == [1, 2])
    }

    @Test func incrementalAppendMatchesFullDistribution() {
        let initial = (0..<10).map { FakeItem(id: $0, aspectRatio: 1) }
        let additional = (10..<15).map { FakeItem(id: $0, aspectRatio: 1) }

        let full = MasonryDistributor.distribute(
            initial + additional, columnCount: 3, columnWidth: 100
        )
        let incremental = MasonryDistributor.appending(
            newItems: additional,
            toExisting: MasonryDistributor.distribute(
                initial, columnCount: 3, columnWidth: 100
            ),
            columnWidth: 100
        )
        #expect(full == incremental)
    }
}

3. Approach A — Layout protocol

Use when item count is bounded and you don't have aspect-ratio metadata. No virtualization.

// Layout/MasonryLayout.swift
import SwiftUI

struct MasonryLayout: Layout {
    var columnCount: Int = 2
    var spacing: CGFloat = 8

    struct Cache {
        var columnWidth: CGFloat = 0
        var itemHeights: [CGFloat] = []
        var placements: [CGPoint] = []
        var totalSize: CGSize = .zero
    }

    func makeCache(subviews: Subviews) -> Cache { Cache() }

    func updateCache(_ cache: inout Cache, subviews: Subviews) {
        // Cache invalidation is handled by SwiftUI — we just reset lazily.
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) -> CGSize {
        guard let proposedWidth = proposal.width, proposedWidth > 0 else {
            return .zero
        }
        computeLayout(in: proposedWidth, subviews: subviews, cache: &cache)
        return cache.totalSize
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout Cache
    ) {
        // Recompute if the width changed since last size pass.
        if cache.columnWidth == 0 || cache.placements.count != subviews.count {
            computeLayout(in: bounds.width, subviews: subviews, cache: &cache)
        }

        for (index, subview) in subviews.enumerated() {
            let origin = cache.placements[index]
            subview.place(
                at: CGPoint(x: bounds.minX + origin.x, y: bounds.minY + origin.y),
                anchor: .topLeading,
                proposal: ProposedViewSize(
                    width: cache.columnWidth,
                    height: cache.itemHeights[index]
                )
            )
        }
    }

    private func computeLayout(
        in totalWidth: CGFloat,
        subviews: Subviews,
        cache: inout Cache
    ) {
        let totalSpacing = spacing * CGFloat(columnCount - 1)
        let columnWidth = max(0, (totalWidth - totalSpacing) / CGFloat(columnCount))

        var columnHeights = Array(repeating: CGFloat.zero, count: columnCount)
        var placements: [CGPoint] = []
        var heights: [CGFloat] = []
        placements.reserveCapacity(subviews.count)
        heights.reserveCapacity(subviews.count)

        for subview in subviews {
            let size = subview.sizeThatFits(
                ProposedViewSize(width: columnWidth, height: nil)
            )

            // argmin column
            var shortest = 0
            for i in 1..<columnCount where columnHeights[i] < columnHeights[shortest] {
                shortest = i
            }

            let x = CGFloat(shortest) * (columnWidth + spacing)
            let y = columnHeights[shortest]
            placements.append(CGPoint(x: x, y: y))
            heights.append(size.height)

            columnHeights[shortest] += size.height + spacing
        }

        cache.columnWidth = columnWidth
        cache.itemHeights = heights
        cache.placements = placements
        cache.totalSize = CGSize(
            width: totalWidth,
            height: (columnHeights.max() ?? 0) - spacing
        )
    }
}

// Usage:
struct LayoutMasonryExample: View {
    let items: [Photo]

    var body: some View {
        ScrollView {
            MasonryLayout(columnCount: 2, spacing: 8) {
                ForEach(items) { photo in
                    MasonryCell(item: photo)
                }
            }
            .padding(8)
        }
    }
}

Gotchas

  • The Cache protects against repeated sizeThatFits calls within a single layout pass, but SwiftUI invalidates it when subviews change identity. If you append items to the array, SwiftUI rebuilds the cache from scratch.
  • sizeThatFits is called with nil height to get the item's natural height. Cells must not be height-greedy, or everything collapses to one row.

4. Approach B — Multi-column LazyVStack (recommended)

// Layout/MasonryView.swift
import SwiftUI

struct MasonryView<Item: MasonryItem, Cell: View>: View {
    let items: [Item]
    let spacing: CGFloat
    let targetColumnWidth: CGFloat
    let onNearBottom: (() -> Void)?
    @ViewBuilder let cell: (Item, CGFloat) -> Cell  // (item, columnWidth)

    @State private var containerWidth: CGFloat = 0
    @State private var columnCount: Int = 2
    @State private var columns: [[Item]] = []

    init(
        items: [Item],
        spacing: CGFloat = 8,
        targetColumnWidth: CGFloat = 180,
        onNearBottom: (() -> Void)? = nil,
        @ViewBuilder cell: @escaping (Item, CGFloat) -> Cell
    ) {
        self.items = items
        self.spacing = spacing
        self.targetColumnWidth = targetColumnWidth
        self.onNearBottom = onNearBottom
        self.cell = cell
    }

    private var columnWidth: CGFloat {
        guard containerWidth > 0, columnCount > 0 else { return targetColumnWidth }
        let totalSpacing = spacing * CGFloat(columnCount + 1)
        return max(0, (containerWidth - totalSpacing) / CGFloat(columnCount))
    }

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 0) {
                HStack(alignment: .top, spacing: spacing) {
                    ForEach(columns.indices, id: \.self) { columnIndex in
                        LazyVStack(spacing: spacing) {
                            ForEach(columns[columnIndex]) { item in
                                cell(item, columnWidth)
                                    .frame(
                                        width: columnWidth,
                                        height: item.renderedHeight(for: columnWidth)
                                    )
                                    .onAppear {
                                        if isNearBottom(item: item) {
                                            onNearBottom?()
                                        }
                                    }
                            }
                        }
                    }
                }
                .padding(.horizontal, spacing)
            }
        }
        .onGeometryChange(for: CGFloat.self) { proxy in
            proxy.size.width
        } action: { newWidth in
            containerWidth = newWidth
            let newColumnCount = max(2, Int(newWidth / targetColumnWidth))
            if newColumnCount != columnCount {
                columnCount = newColumnCount
                redistribute()
            }
        }
        .onChange(of: items) { _, _ in redistribute() }
    }

    private func redistribute() {
        guard columnWidth > 0 else { return }
        columns = MasonryDistributor.distribute(
            items,
            columnCount: columnCount,
            columnWidth: columnWidth,
            spacing: spacing
        )
    }

    private func isNearBottom(item: Item) -> Bool {
        // Trigger when item is in the last 10% of any column
        for column in columns {
            if let idx = column.firstIndex(of: item),
               idx >= column.count - max(1, column.count / 10) {
                return true
            }
        }
        return false
    }
}

Why the outer LazyVStack wraps the HStack

It isn't strictly necessary for layout, but it plays nicely with ScrollView's scheduling on iOS 26 and avoids a known quirk where HStack inside ScrollView can eagerly evaluate its children's frames. Remove if you measure no difference.

Usage

@State private var photos: [Photo] = []
@State private var isLoading = false

var body: some View {
    MasonryView(
        items: photos,
        spacing: 8,
        targetColumnWidth: 180,
        onNearBottom: { Task { await loadNextPage() } }
    ) { photo, width in
        AsyncMasonryImage(url: photo.url, aspectRatio: photo.aspectRatio)
    }
}

5. Cell with async image + stable height

// Cells/AsyncMasonryImage.swift
import SwiftUI

struct AsyncMasonryImage: View, Equatable {
    let url: URL
    let aspectRatio: CGFloat

    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.url == rhs.url && lhs.aspectRatio == rhs.aspectRatio
    }

    var body: some View {
        AsyncImage(url: url) { phase in
            switch phase {
            case .empty:
                Rectangle()
                    .fill(.gray.opacity(0.1))
                    .aspectRatio(aspectRatio, contentMode: .fill)
            case .success(let image):
                image
                    .resizable()
                    .aspectRatio(aspectRatio, contentMode: .fill)
            case .failure:
                Rectangle()
                    .fill(.gray.opacity(0.2))
                    .aspectRatio(aspectRatio, contentMode: .fill)
                    .overlay(Image(systemName: "photo"))
            @unknown default:
                EmptyView()
            }
        }
        .clipped()
        .contentShape(Rectangle())
    }
}

Critical detail: the .empty and .failure placeholders use the same .aspectRatio(_:contentMode:) as the loaded image. Without this, the cell's rendered height changes on image load, which:

  1. Breaks LazyVStack's scroll offset estimates
  2. Causes cascading re-layouts throughout the visible region
  3. Makes the feed "jump" as images resolve

The Equatable conformance tells SwiftUI to skip re-evaluating unchanged cells — important when parent re-renders for unrelated reasons.


6. Image prefetching

AsyncImage alone will stutter at 120Hz because it loads only when the view appears. Warm images ±N cells ahead.

// Prefetch/ImagePrefetcher.swift
import SwiftUI

@Observable
@MainActor
final class ImagePrefetcher {
    private let session: URLSession
    private var inFlight: [URL: Task<Void, Never>] = [:]
    private let maxConcurrent = 4

    init() {
        let config = URLSessionConfiguration.default
        config.requestCachePolicy = .returnCacheDataElseLoad
        config.urlCache = URLCache(
            memoryCapacity: 64 * 1024 * 1024,   // 64 MB
            diskCapacity:   256 * 1024 * 1024   // 256 MB
        )
        self.session = URLSession(configuration: config)
    }

    func prefetch(_ urls: [URL]) {
        for url in urls where inFlight[url] == nil {
            if inFlight.count >= maxConcurrent { return }
            inFlight[url] = Task { [weak self] in
                _ = try? await self?.session.data(from: url)
                await MainActor.run { self?.inFlight[url] = nil }
            }
        }
    }

    func cancel(_ urls: [URL]) {
        for url in urls {
            inFlight[url]?.cancel()
            inFlight[url] = nil
        }
    }
}

Wire into the masonry view:

@State private var prefetcher = ImagePrefetcher()

// In .onAppear of cell at index i:
let lookahead = 20
let upcoming = items
    .dropFirst(i)
    .prefix(lookahead)
    .map(\.url)
prefetcher.prefetch(Array(upcoming))

AsyncImage will then hit the URL cache immediately when those cells scroll into view.


7. Pagination

Two rules make pagination smooth:

  1. Append, never replace. Redistributing the whole feed on every page kills performance and causes visible jumps.
  2. Debounce. The onAppear-triggered onNearBottom can fire multiple times before the request completes.
@MainActor
@Observable
final class MasonryFeedViewModel {
    var photos: [Photo] = []
    private(set) var isLoading = false
    private var nextCursor: String?
    private var hasMore = true

    func loadInitial() async { await loadPage(reset: true) }

    func loadNextIfNeeded() async {
        guard !isLoading, hasMore else { return }
        await loadPage(reset: false)
    }

    private func loadPage(reset: Bool) async {
        isLoading = true
        defer { isLoading = false }

        do {
            let page = try await PhotoAPI.fetch(cursor: reset ? nil : nextCursor)
            if reset {
                photos = page.items
            } else {
                photos.append(contentsOf: page.items)
            }
            nextCursor = page.nextCursor
            hasMore = page.nextCursor != nil
        } catch {
            // Surface via a toast or inline banner — don't swallow silently.
        }
    }
}

The .append(contentsOf:) matters: SwiftUI's diffing will detect only the appended IDs and insert just those cells, not rebuild the entire ForEach.


8. Validation workflow

Run these in order. Do not skip to the next until the current one passes.

  1. Distributor unit testsswift test, all green.
  2. Visual correctness — static feed of 30 items, 2 columns. No gaps, no overlap, bottom edges reasonably balanced.
  3. Virtualization check — Instruments → Allocations. Scroll 5,000 items; memory should stay flat (±20 MB). If it climbs linearly, cells are not being deallocated — likely a retain cycle in onAppear or a missing Equatable.
  4. 120Hz scroll — Instruments 26 → SwiftUI instrument on ProMotion device. Orange/red update bars during scroll = problem. Green = ship it.
  5. Adaptive column count — Run on iPad. Resize Split View to 33/50/66/100%. Column count changes, no crashes, scroll position remains stable (or document why it resets).
  6. Prefetch off — Disable ImagePrefetcher. Scroll rapidly. Confirm stutter. Re-enable. Confirm smooth. This proves prefetching is doing work; if no difference, cache is already warm.
  7. Swift 6 strict concurrency — Build with -strict-concurrency=complete. Zero warnings.

9. What to measure

Metric Tool Target
Dropped frames during scroll Instruments → Animation Hitches 0 at 120Hz
Memory growth after 10k-item scroll Instruments → Allocations < +20 MB
Time to first paint Instruments → Time Profiler < 100ms
Main-thread time per update SwiftUI instrument < 4ms (for 240Hz headroom)
Image cache hit rate Custom logging > 80% after scroll

Resources

Apple: Layout protocol, LazyVStack, ScrollView, onGeometryChange, AsyncImage, URLCache WWDC: 2022-10056 (Compose custom layouts with SwiftUI), 2023-10160 (Demystify SwiftUI performance), 2025 SwiftUI Instrument session Related Axiom skills: axiom-swiftui (skills/layout.md, skills/swiftui-performance.md), axiom-concurrency (for the prefetcher's isolation model)

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