Designing a custom lazy list in SwiftUI with better performance
Mac apps often need to handle large datasets efficiently, but SwiftUI’s standard List
can struggle with performance on macOS as the number of items grows. Scrolling may become sluggish, and memory usage can increase significantly.
For example, an app that enumerates files in a folder can easily generate a list with over 10,000 rows. While List
is the obvious choice, its performance degrades at scale. A common alternative is wrapping a LazyHStack
in a ScrollView
, but this approach also struggles with large datasets.
So what’s the solution? We can build a custom layout that aggressively recycles rows, repositioning them just in time as the user scrolls while reusing the same view identity as a row fragment. This works particularly well with a fixed row height, which is common in macOS applications, since it allows us to determine visible rows based on the scroll offset. While this solution was designed for macOS, the same technique can also be applied to iOS.
The first step is to create a view that calculates the visible range of rows within the ScrollView
. This allows us to determine which rows should be displayed at any given time.
struct RecyclingScrollingLazyView {
let numberOfRows: Int
let rowHeight: CGFloat
@State var visibleRange: Range<Int> = 0..<1
var body: some View {
ScrollView(.vertical) {
Color.red.frame(
height: rowHeight * CGFloat(numberOfRows)
)
}
.onScrollGeometryChange(
for: Range<Int>.self,
of: { geo in
self.computeVisibleRange(in: geo.visibleRect)
},
action: { oldValue, newValue in
self.visibleRange = newValue
}
)
}
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
}
}
Here, we create a vertical ScrollView
and use the onScrollGeometryChange() modifier. This modifier is powerful because it calls the transform
method every time the scroll offset updates but only triggers the action
block if the result of the transform
changes.
Next, we need to ensure that SwiftUI views, and any embedded UIKit or AppKit views, are reused as much as possible. To achieve this, we treat each reusable row as a fragment. We also need to track the number of fragments we expect to use, updating this value in rowFragments
as needed.
struct RecyclingScrollingLazyView {
let numberOfRows: Int
let rowHeight: CGFloat
@State var visibleRange: Range<Int> = 0..<1
@State var rowFragments: Int = 1
var body: some View {
ScrollView(.vertical) {
Color.red.frame(
height: rowHeight * CGFloat(numberOfRows)
)
}
.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
)
}
)
}
...
}
With a computed number of fragments, we now need to create the rows. Our RecyclingScrollingLazyView
will take a list of row IDs and a ViewBuilder
closure to create each row given its ID.
struct RecyclingScrollingLazyView<
ID: Hashable, Content: View
>: View {
let rowIDs: [ID]
let rowHeight: CGFloat
@ViewBuilder
var content: (ID) -> Content
var numberOfRows: Int { rowIDs.count }
...
}
The updated view is generic in both the row ID and the row content.
We can now use it as follows:
RecyclingScrollingLazyView(
rowIDs: [1, 2, 3], rowHeight: 42
) { id in
HStack {
Text("Row \(id)")
Spacer()
Text("Some other row content")
}
}
So far, our view only renders a large red rectangle without displaying any rows. To fix this, we need to map the visible range of rows to actual row content.
Since we want SwiftUI to reuse views efficiently, we’ll introduce a reusable identifier for each row, which we'll call a fragment. To achieve this, we'll define a nested struct called RowData
inside our view. This struct will help manage row identity and ensure proper recycling of views as the user scrolls.
struct RecyclingScrollingLazyView<
ID: Hashable, Content: View
>: View {
...
struct RowData: Identifiable {
let fragmentID: Int
let index: Int
let value: ID
var id: Int { fragmentID }
}
...
}
Next, we’ll define a computed property that returns the visible range of rows as RowData
. This ensures that only the necessary rows are displayed while efficiently reusing fragments.
struct RecyclingScrollingLazyView<
ID: Hashable, Content: View
>: View {
...
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
}
}
We determine the visible rows by slicing rowIDs
based on the computed visible range. To maximize reuse, we assign each row a fragment ID, calculated as the row index modulo rowFragments
.
We can now replace the large empty rectangle with actual rows inside the scroll view.
struct RecyclingScrollingLazyView<
ID: Hashable, Content: View
>: View {
...
var body: some View {
ScrollView(.vertical) {
ForEach(visibleRows) { row in
content(row.value)
}
}
.onScrollGeometryChange(
...
)
}
}
The key to this approach is ensuring that our nested struct conforms to Identifiable and uses the fragment ID as the row identifier. This allows SwiftUI to reuse rows as we scroll, rather than creating new leaf nodes in the SwiftUI view hierarchy. By maintaining consistent view identities, we ensure that off-screen rows are efficiently recycled instead of being recreated.
However, if we run this now, we'll notice that only a small number of rows appear, always positioned at the top of the ScrollView
. This happens because we're not explicitly positioning the rows or defining the total height of the content.
To fix this, we need a custom layout that places each row at the correct vertical position while ensuring the total height matches the full dataset.
We'll define an OffsetLayout
struct that conforms to the Layout protocol, allowing precise control over row positioning.
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
}
The sizeThatFits()
method calculates the total height by multiplying the number of rows by the height of each row. This ensures the scrollable area reflects the full dataset.
In placeSubviews()
, each visible row is positioned at its correct vertical offset. To determine its position, we need to know its index within the full list. We achieve this by defining a LayoutValueKey, which allows us to pass the index to the layout.
struct RecyclingScrollingLazyView<
ID: Hashable, Content: View
>: View {
...
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(
...
)
}
}
Using the custom OffsetLayout
, we can position each visible row precisely in the scroll view according to its index. The LayoutIndex
value is set on each row and read back within our custom layout.
Since our approach reuses a limited number of rows, local state can persist when a row is recycled. For example, storing user input in a @State
property may cause it to appear in a different row after scrolling.
To avoid this issue, we need to reset or clear any local state whenever the row ID changes.
struct MyRow: View {
let id: Int
@State private var text: String = ""
var body: some View {
TextField("Enter something", text: $text)
.onChange(of: id) { newID in
self.text = ""
}
}
}
RecyclingScrollingLazyView(
rowIDs: [1, 2, 3], rowHeight: 42
) { id in
MyRow(id: id)
}
Our custom approach is faster because we reuse a limited set of view identities instead of creating a new view for every row in a large list. We achieve this by calculating a fragment ID, which is the row’s index modulo the maximum number of visible rows. This allows SwiftUI to recycle view identities as rows move off-screen, rather than instantiating new views each time. By reusing existing views, we significantly improve performance and reduce memory usage.
In contrast, built-in components like List
and LazyHStack
cannot reuse views as aggressively. They assign each row a unique identity (provided in the ForEach
statement) to properly maintain per-row state. As a result, SwiftUI’s view graph must create a separate leaf for each row and compute its height individually—an expensive process as the number of rows grows.
The performance difference becomes even more pronounced when using AppKit-backed controls such as text views, sliders, or buttons. By reusing view identities, previously instantiated AppKit views remain attached and are efficiently recycled, much like how a native NSTableView
optimizes performance at scale.
If you're looking to gain a deeper understanding of SwiftUI state management, view identity, layout system, and rendering internals, make sure to check out SwiftUI Fundamentals by Natalia Panferova. It explores these core concepts in depth, helping you write more efficient and scalable SwiftUI apps.