-
-
Save fatbobman/d47e7d11df5ce5d3c42f032e27457eed 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
// 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