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
- Fast-scroll to the bottom of the list using the scroll indicator.
- Then fast-scroll back to the top by dragging and holding the scroll indicator.
Actual Results
- Blank Screen
During fast scrolling, the visible content may temporarily disappear, resulting in a white screen. In most cases, the content eventually reappears.
- 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):
SwiftUICore::resolveIndexAndPositionproduces:
anchorIndex = 2
anchorPosition = -562
- The content is translated by 486.47, which corresponds approximately to:
estimatedCellSize * 2
- As a result, the effective anchor position becomes:
-76.32and the first0...1cells 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?