Skip to content

Instantly share code, notes, and snippets.

@gtokman
Created March 9, 2025 15:22
Show Gist options
  • Save gtokman/0ea637afe3f87f371d25c157b97cd510 to your computer and use it in GitHub Desktop.
Save gtokman/0ea637afe3f87f371d25c157b97cd510 to your computer and use it in GitHub Desktop.
RecyclingScrollingLazyView
import SwiftUI
// https://nilcoalescing.com/blog/
@available(iOS 18.0, *)
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
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
}
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 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 {
nonisolated(unsafe) 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) { old, newID in
self.text = ""
}
}
}
#Preview {
if #available(iOS 18.0, *) {
RecyclingScrollingLazyView(
rowIDs: Array(1...1000), rowHeight: 42
) { id in
MyRow(id: id)
}
} else {
// Fallback on earlier versions
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment