Created
March 9, 2025 15:22
-
-
Save gtokman/0ea637afe3f87f371d25c157b97cd510 to your computer and use it in GitHub Desktop.
RecyclingScrollingLazyView
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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