import SwiftUI /// Masonry layout for a lane's interior card columns (03-board-ui.md § Layout — full visibility: /// "a wide lane flows them into as many interior masonry columns as it has units"; § Lane: "masonry /// grid when wide — settled, the pathfinder's masonry works"). /// /// Children are assigned round-robin to `columns` equal-width vertical columns (child `i` → column /// `i % columns`), and each column stacks its children top-aligned and independently — there is /// **no row alignment across columns**. With uniform card heights this renders exactly like a /// row-major grid, but when one card grows taller (the sole selected card's attachment carousel, /// 03-board-ui.md § Card face) it only pushes the cards below it in its *own* column; the /// neighbouring columns do not move. /// /// A `Layout` rather than an `HStack` of per-column `VStack`s so the caller keeps a single /// `ForEach` — reflowing cards across columns preserves view identity and animates as positional /// moves, not as remove/insert transitions. That is what lets the interior reflow *during* a resize /// drag read as cards sliding rather than blinking. /// /// **Vocabulary note.** In Lanework a "lane" is the kanban column; this layout's own interior /// tracks are "columns". The pathfinder called them lanes, which is why the ported reasoning below /// reads the way it does. struct MasonryLayout: Layout { /// Number of interior columns (the lane's width units); clamped to ≥ 1. var columns: Int /// Spacing between columns and between stacked cards within a column. var spacing: CGFloat private var columnCount: Int { max(1, columns) } private func columnWidth(for totalWidth: CGFloat) -> CGFloat { max(0, (totalWidth - spacing * CGFloat(columnCount - 1)) / CGFloat(columnCount)) } // MARK: - Measurement cache // // A lane with several hundred cards is measured a LOT: SwiftUI probes a layout's `sizeThatFits` // more than once per pass (different proposals), and `placeSubviews` needs every height again // right after. Unmemoized that is `subviews.count` full subtree measurements per call. // // The cache holds one height per subview, keyed by the column width they were measured at: // column width is the only thing this layout ever proposes (height is always `nil`, so a card's // height is a pure function of its width and its content). A different column width — a window // resize, a width change — discards the whole table, which is correct and cheap: it is exactly // the case where every height really did change. // // Staleness is handled by SwiftUI itself: `updateCache` runs whenever the layout's subviews // change, which is the only way a card's measured height can change at a fixed column width // (its content changed → its view tree was rebuilt → the layout's content is new). Clearing // there means the cache never outlives the content it measured. struct Cache { var columnWidth: CGFloat = .nan var heights: [CGFloat] = [] } func makeCache(subviews: Subviews) -> Cache { Cache() } func updateCache(_ cache: inout Cache, subviews: Subviews) { cache = Cache() } /// `subviews[index]`'s height at `column` width, measured once per (content, column width) /// generation and reused for every later probe and for the placement pass. private func height(of subviews: Subviews, at index: Int, column: CGFloat, cache: inout Cache) -> CGFloat { if cache.columnWidth != column || cache.heights.count != subviews.count { cache.columnWidth = column cache.heights = [CGFloat](repeating: .nan, count: subviews.count) } if !cache.heights[index].isNaN { return cache.heights[index] } let height = subviews[index].sizeThatFits(ProposedViewSize(width: column, height: nil)).height cache.heights[index] = height return height } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize { let width = proposal.width ?? 0 let column = columnWidth(for: width) var heights = [CGFloat](repeating: 0, count: columnCount) for index in subviews.indices { let height = height(of: subviews, at: index, column: column, cache: &cache) let target = index % columnCount heights[target] += height + (heights[target] > 0 ? spacing : 0) } return CGSize(width: width, height: heights.max() ?? 0) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) { let column = columnWidth(for: bounds.width) var y = [CGFloat](repeating: bounds.minY, count: columnCount) for index in subviews.indices { let target = index % columnCount let x = bounds.minX + CGFloat(target) * (column + spacing) let height = height(of: subviews, at: index, column: column, cache: &cache) subviews[index].place(at: CGPoint(x: x, y: y[target]), proposal: ProposedViewSize(width: column, height: height)) y[target] += height + spacing } } }