1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import SwiftUI
/// A fixed-aspect grid that only builds the rows near the visible part of
/// the scroll view it sits in.
///
/// `LazyVGrid` is lazy only along the axis of the *nearest* scroll view. The
/// profile's tabs put each grid inside a horizontal pager inside the vertical
/// scroll view, and there every cell was built at once: a profile with three
/// hundred galleries decoded three hundred thumbnails on open, and the last
/// cell's `onAppear` paged through the rest of the history straight away.
///
/// This grid does the bookkeeping itself. Every cell is the same size, so
/// which row a scroll offset lands on is arithmetic. It renders the rows
/// within a viewport of the visible band and pads above and below, and the
/// padding keeps the total height exactly what the full grid would measure.
/// The viewport is read through the named coordinate space of the scroll
/// view that actually scrolls, which nesting can't hide.
struct WindowedGrid<Item: Identifiable, Cell: View>: View {
let items: [Item]
/// Name given to the vertically scrolling ancestor with `coordinateSpace(.named:)`.
let scrollSpace: String
var columns = 3
var spacing: CGFloat = 2
/// Width divided by height of every cell.
var aspectRatio: CGFloat = 3.0 / 4.0
/// Builds one cell. The size is what the cell will be laid out at, for
/// callers that want to size an image request to it.
@ViewBuilder let cell: (Item, CGSize) -> Cell
@State private var window = Window()
/// The rows worth building, and the width they were computed for.
struct Window: Equatable {
var width: CGFloat = 0
var rows: Range<Int> = 0 ..< 0
}
/// Where every row of a grid of `count` items lands, for a given width.
struct Layout: Equatable {
let count: Int
let columns: Int
let spacing: CGFloat
let cellSize: CGSize
let rowCount: Int
init(count: Int, columns: Int, spacing: CGFloat, aspectRatio: CGFloat, width: CGFloat) {
self.count = count
self.columns = max(columns, 1)
self.spacing = spacing
let cellWidth = max((width - spacing * CGFloat(self.columns - 1)) / CGFloat(self.columns), 0)
cellSize = CGSize(width: cellWidth, height: cellWidth / aspectRatio)
rowCount = count > 0 ? (count + self.columns - 1) / self.columns : 0
}
/// Distance from one row's top to the next row's top.
var rowPitch: CGFloat {
cellSize.height + spacing
}
/// Height of the whole grid, padding included.
var height: CGFloat {
rowCount > 0 ? CGFloat(rowCount) * rowPitch - spacing : 0
}
/// The rows within `overscan` viewports of the visible band. `minY` is
/// the grid's top edge relative to the viewport's top edge.
func rows(visibleFrom minY: CGFloat, viewportHeight: CGFloat, overscan: CGFloat = 1) -> Range<Int> {
guard rowCount > 0, rowPitch > 0 else { return 0 ..< 0 }
let top = -minY - viewportHeight * overscan
let bottom = -minY + viewportHeight * (1 + overscan)
let lower = min(max(Int((top / rowPitch).rounded(.down)), 0), rowCount)
let upper = min(max(Int((bottom / rowPitch).rounded(.up)), lower), rowCount)
return lower ..< upper
}
}
var body: some View {
let layout = Layout(count: items.count, columns: columns, spacing: spacing, aspectRatio: aspectRatio, width: window.width)
let rows = window.rows.clamped(to: 0 ..< layout.rowCount)
Group {
if rows.isEmpty {
Color.clear.frame(height: layout.height)
} else {
VStack(spacing: spacing) {
ForEach(rows, id: \.self) { row in
HStack(spacing: spacing) {
ForEach(itemsInRow(row, layout: layout)) { item in
cell(item, layout.cellSize)
.frame(width: layout.cellSize.width, height: layout.cellSize.height)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.top, CGFloat(rows.lowerBound) * layout.rowPitch)
.padding(.bottom, CGFloat(layout.rowCount - rows.upperBound) * layout.rowPitch)
}
}
.frame(maxWidth: .infinity)
.onGeometryChange(for: Window.self) { proxy in
let width = proxy.size.width
let layout = Layout(count: items.count, columns: columns, spacing: spacing, aspectRatio: aspectRatio, width: width)
let space = NamedCoordinateSpace.named(scrollSpace)
guard let viewport = proxy.bounds(of: space) else {
// No such scroll view above us: build everything, as a plain
// grid would, rather than guess at what's visible.
return Window(width: width, rows: 0 ..< layout.rowCount)
}
let minY = proxy.frame(in: space).minY
return Window(width: width, rows: layout.rows(visibleFrom: minY, viewportHeight: viewport.height))
} action: { newWindow in
window = newWindow
}
}
private func itemsInRow(_ row: Int, layout: Layout) -> ArraySlice<Item> {
let start = row * layout.columns
let end = min(start + layout.columns, items.count)
guard start < end else { return [] }
return items[start ..< end]
}
}