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 } }