NEW BOOK! SwiftUI Fundamentals: The essential guide to SwiftUI core concepts and APIs. Learn more ...NEW BOOK! SwiftUI Fundamentals:Master SwiftUI core concepts and APIs. Learn more...

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.

SwiftUI Fundamentals by Natalia Panferova book coverSwiftUI Fundamentals by Natalia Panferova book cover

Deepen your understanding of SwiftUI!$35

The essential guide to SwiftUI core concepts and APIs

SwiftUI Fundamentalsby Natalia Panferova

  • Explore the key APIs and design patterns that form the foundation of SwiftUI
  • Develop a deep, practical understanding of how SwiftUI works under the hood
  • Learn from a former Apple engineer who worked on widely used SwiftUI APIs

Deepen your understanding of SwiftUI!

The essential guide to SwiftUI core concepts and APIs

SwiftUI Fundamentals by Natalia Panferova book coverSwiftUI Fundamentals by Natalia Panferova book cover

SwiftUI Fundamentals

by Natalia Panferova

$35