LazyVStack layout issue and potential fix

Hello!

Over the last several months I've spent a lot of time investigating various LazyVStack issues. I’d like to share a one problem.

Blank Screen + Cell trimming

For reference, I've also submitted a Feedback Assistant report for this issue.

Let's use the minimal example
Copy the following code into a new project and run it:

struct ScrollableLazyVStack: View {
    @StateObject private var viewModel: ScrollableLazyVStackViewModel
    
    init(count: Int) {
        self._viewModel = StateObject(
            wrappedValue: ScrollableLazyVStackViewModel(count: count)
        )
    }

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {
                ForEach(viewModel.items, id: \.index) {
                    CellView(by: $0)
                }
            }
        }
        .padding(.horizontal, 8)
    }
}

final class ScrollableLazyVStackViewModel: ObservableObject {
    @Published var items: [CellModel] = []
    
    init(count: Int) {
        self.items = (0..<count).map {
            CellModel(index: $0)
        }
    }
}

private struct CellView: View {
    let item: CellModel
    
    init(by item: CellModel) {
        self.item = item
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 2) {
            HStack(spacing: 4) {
                Image(systemName: item.icon)
                
                VStack(alignment: .leading, spacing: 4) {
                    Text(item.title)
                        .font(.body)

                    Text(item.subtitle)
                        .font(.caption)
                        .foregroundColor(.secondary)
                    
                    if item.index < 100 {
                        Text("Extra Text")
                            .frame(height: 500)
                    }
                }
                Spacer(minLength: .zero)
            }
        }
        .padding(16)
        .background(
            RoundedRectangle(cornerRadius: 14, style: .continuous)
                .fill(Color(.systemGray5))
        )
    }
}

struct CellModel: Hashable {
    private static let icons = [
        "star.fill", "heart.fill", "bolt.fill", "flame.fill", "leaf.fill", "moon.fill", "cloud.fill", "paperplane.fill"
    ]
    
    private static let titles = [
        "Random Item", "Sample Entry", "List Element", "Demo Cell", "Example Row", "Test Object"
    ]

    private static let subtitles = [
        "Additional information", "Secondary description", "Some extra details", "Short explanation", "Supporting text"
    ]
    
    let index: Int
    let title: String
    let subtitle: String
    let icon: String
    
    init(index: Int) {
        self.index = index
        self.title = "\(index). \(Self.titles.randomElement()!)"
        self.subtitle = Self.subtitles.randomElement()!
        self.icon = Self.icons.randomElement()!
    }
}

Steps to Reproduce

  1. Fast-scroll to the bottom of the list using the scroll indicator.
  2. Then fast-scroll back to the top by dragging and holding the scroll indicator.

Actual Results

  1. Blank Screen

During fast scrolling, the visible content may temporarily disappear, resulting in a white screen. In most cases, the content eventually reappears.

  1. Truncated Cells

After scrolling back toward the top, some of the topmost cells may become partially truncated.

Possible Cause

Blank Screen

I do not want to rely on or investigate SwiftUI's non-public implementation details, but I would like to explain what I believe is happening internally. I'd like to assume what appears to be the underlying algorithm.

When fast scrolling is active, the following sequence seems to occur:

Step 1

SwiftUICore::resolveIndexAndPosition calculates an anchorIndex.

For example: anchorIndex ≈ (scrollOffset / contentSize) * totalRows

Step 2

SwiftUICore::resolveIndexAndPosition calculates an anchorPosition.

For example: anchorPosition ≈ anchorIndex * (estimatedCellSize + spacing)

Step 3

SwiftUI searches for the first visible cell and updates the position of the last laid-out cell (y_max_last_cell).

Step 4 (the most important)

The layout's correctness validation:

Conceptually:

d1 = abs(y_last_max - cachedSizeThatFits)
d2 = 0.1 * min(y_last_max, cachedSizeThatFits)

If: d1 < d2 -> the current CA::Transaction is committed.
Otherwise: cachedSizeThatFits = sizeThatFits(...) and the process restarts from Step 1.

If logging is enabled via: com.apple.SwiftUI.LazyStackLogging the behavior can be observed in the attached screenshot.

Why this may fail

The algorithm appears to assume that the estimated cell size does not change dramatically while fast scrolling.

However, if the beginning of the list contains very tall cells and the remainder contains much smaller cells, the estimate may become significantly inaccurate.

In that situation, comparing cachedSizeThatFits against the position of the last visible cell does not seem sufficient to guarantee a stable result, which may explain the temporary blank screen.

Cell Truncation

While scrolling upward, the following state can occur (see attached screenshot):

  1. SwiftUICore::resolveIndexAndPosition produces:

anchorIndex = 2
anchorPosition = -562

  1. The content is translated by 486.47, which corresponds approximately to:

estimatedCellSize * 2

  1. As a result, the effective anchor position becomes: -76.32 and the first 0...1 cells are truncated.

Possible Solution

Blank screen's problem

My assumption is that the current layout validation criteria may not be sufficient in all cases. An alternative approach could be to always fill the visible region sequentially:

  • top → bottom while scrolling downward
  • bottom → top while scrolling upward

With such a strategy, the layout result could potentially be committed immediately once the visible area is fully covered by realized cells.

Cell's Truncation

For the truncation issue, it seems that the current translation-based approach may be the source of the problem.

One possible alternative would be to use a contentInset-based adjustment combined with clipping against the first visible cell's minY.

In that model, negative inset values would not necessarily be problematic, and the visible content could remain correctly aligned.

What do you think about this?

LazyVStack layout issue and potential fix
 
 
Q