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.
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
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.
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/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)
}
}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
Cacheprotects against repeatedsizeThatFitscalls 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. sizeThatFitsis called withnilheight to get the item's natural height. Cells must not be height-greedy, or everything collapses to one row.
// 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
}
}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.
@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)
}
}// 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:
- Breaks
LazyVStack's scroll offset estimates - Causes cascading re-layouts throughout the visible region
- 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.
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.
Two rules make pagination smooth:
- Append, never replace. Redistributing the whole feed on every page kills performance and causes visible jumps.
- Debounce. The
onAppear-triggeredonNearBottomcan 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.
Run these in order. Do not skip to the next until the current one passes.
- Distributor unit tests —
swift test, all green. - Visual correctness — static feed of 30 items, 2 columns. No gaps, no overlap, bottom edges reasonably balanced.
- 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
onAppearor a missingEquatable. - 120Hz scroll — Instruments 26 → SwiftUI instrument on ProMotion device. Orange/red update bars during scroll = problem. Green = ship it.
- 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).
- 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. - Swift 6 strict concurrency — Build with
-strict-concurrency=complete. Zero warnings.
| 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 |
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)