Skip to content

Instantly share code, notes, and snippets.

@fatbobman
Created March 5, 2025 01:22
Show Gist options
  • Save fatbobman/d47e7d11df5ce5d3c42f032e27457eed to your computer and use it in GitHub Desktop.
Save fatbobman/d47e7d11df5ce5d3c42f032e27457eed to your computer and use it in GitHub Desktop.
RecyclingScrollingLazyView
// Code by Matthaus Woolard from https://nilcoalescing.com/blog/CustomLazyListInSwiftUI/
import SwiftUI
struct ContentView: View {
var body: some View {
RecyclingScrollingLazyView(
rowIDs: [1, 2, 3], rowHeight: 42
) { id in
MyRow(id: id)
}
}
}
#Preview {
ContentView()
}
struct RecyclingScrollingLazyView<
ID: Hashable, Content: View
>: View {
let rowIDs: [ID]
let rowHeight: CGFloat
@ViewBuilder
var content: (ID) -> Content
var numberOfRows: Int { rowIDs.count }
@State var visibleRange: Range<Int> = 0 ..< 1
@State var rowFragments: Int = 1
var body: some View {
ScrollView(.vertical) {
OffsetLayout(
totalRowCount: rowIDs.count,
rowHeight: rowHeight
) {
// The fragment ID is used instead of the row ID
ForEach(visibleRows) { row in
content(row.value)
.layoutValue(
key: LayoutIndex.self, value: row.index
)
}
}
}
.onScrollGeometryChange(
for: Range<Int>.self,
of: { geo in
self.computeVisibleRange(in: geo.visibleRect)
},
action: { oldValue, newValue in
self.visibleRange = newValue
self.rowFragments = max(
newValue.count, rowFragments
)
}
)
}
func computeVisibleRange(in rect: CGRect) -> Range<Int> {
let lowerBound = Int(
max(0, floor(rect.minY / rowHeight))
)
let upperBound = max(
Int(ceil(rect.maxY / rowHeight)),
lowerBound + 1
)
return lowerBound ..< upperBound
}
struct RowData: Identifiable {
let fragmentID: Int
let index: Int
let value: ID
var id: Int { fragmentID }
}
var visibleRows: [RowData] {
if rowIDs.isEmpty { return [] }
let lowerBound = min(
max(0, visibleRange.lowerBound),
rowIDs.count - 1
)
let upperBound = max(
min(rowIDs.count, visibleRange.upperBound),
lowerBound + 1
)
let range = lowerBound ..< upperBound
let rowSlice = rowIDs[lowerBound ..< upperBound]
let rowData = zip(rowSlice, range).map { row in
RowData(
fragmentID: row.1 % max(rowFragments, range.count),
index: row.1, value: row.0
)
}
return rowData
}
}
struct OffsetLayout: Layout {
let totalRowCount: Int
let rowHeight: CGFloat
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
CGSize(
width: proposal.width ?? 0,
height: rowHeight * CGFloat(totalRowCount)
)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
for subview in subviews {
let index = subview[LayoutIndex.self]
subview.place(
at: CGPoint(
x: bounds.midX,
y: bounds.minY + rowHeight * CGFloat(index)
),
anchor: .top,
proposal: .init(
width: proposal.width, height: rowHeight
)
)
}
}
}
struct LayoutIndex: LayoutValueKey {
static var defaultValue: Int = 0
typealias Value = Int
}
struct MyRow: View {
let id: Int
@State private var text: String = ""
var body: some View {
TextField("Enter something", text: $text)
.onChange(of: id) {
self.text = ""
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment