Unexpected behavior in the interaction between LazyVStack and GeometryReader

Hello!

I'd like to share a problem and its potential solution.

Steps to reproduce:

The issue can be reproduced with the following minimal example:

struct TestConditionalScrollView: View {
    var body: some View {
        ConditionalScrollView {
            LazyVStack(spacing: 16) {
                Text("Text 1")
                    .frame(height: 20)
                
                Text("Text 2")
                    .frame(height: 30)
                
                Text("Text 3")
                    .frame(height: 40)
                
                Text("Text 4")
                    .frame(height: 500)
                
                Text("Text 5")
                    .frame(height: 400)
            }
            .padding()
        }
    }
}

struct ConditionalScrollView<Content: View>: View {

    let content: Content

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    @State private var contentHeight: CGFloat = 0
    
    var body: some View {
        GeometryReader { geo in
            _ = print("Height: \(contentHeight)")
            return Group {
                if contentHeight > geo.size.height {
                    ScrollView {
                        measuredContent
                    }
                } else {
                    measuredContent
                }
            }
        }
    }

    private var measuredContent: some View {
        content
            .background(
                GeometryReader { geo in
                    Color.clear
                        .preference(
                            key: ContentHeightKey.self,
                            value: geo.size.height
                        )
                }
            )
            .onPreferenceChange(ContentHeightKey.self) {
                contentHeight = $0
            }
    }
}

struct ContentHeightKey: PreferenceKey {
    static var defaultValue: CGFloat = 0

    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

If we hit the breakpoint on the following line:

_ = print("Height: (contentHeight)")

the output looks like this:

Problems observed

  1. Different values are reported, and it is unclear where those values originate from.
  2. The selected execution branch appears to change multiple times during the layout process.

Possible reason

As Rens Breur mentioned in the WWDC26 session "Dive into lazy stacks and scrolling with SwiftUI", LazyVStack relies on estimated layout information during certain phases of the layout process.

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.

To do that, let me show how the value reaches the GeometryReader closure:

Step 1

In AttributeGraph, the GeometryReader<...> node and the LazyVStack node appear to be connected as shown below:

Step 2

When the LazyVStack node is updated, the layout process appears to follow roughly this logic:

`SwiftUICore 'SwiftUI.ForEachState.forEachItem:`
    n = number of cells to evaluate (initially n == 2)

    For first n cells:
        SwiftUICore`SwiftUI.ViewLayoutEngine.sizeThatFits(...)
        SwiftUI.EstimationCache.add(...)

The total size of the lazy stack is then estimated:
SwiftUI.LazyStack<...>.sizeThatFits(...):
    averageCellInfo = EstimationCache.average
    averageCellInfo.height = (firstCellHeight + secondCellHeight) / 2

    totalHeight =
        firstCellHeight +
        secondCellHeight +
        averageCellInfo.height * remainingCells

For the sample project, this produces an estimated height of 189.

This value then appears to be cached inside a LazyLayoutComputer node.

Step 3

When GeometryReader is updated and its closure executes, geo.size.height appears to be resolved from the cached value stored by LazyLayoutComputer.

As a result, the reported height is: 189 + 32 (padding) = 221

Step 4

My assumption is that LazyVStack subsequently validates the estimated layout against the actual layout results.

It seems to compare:

  • The maximum Y position of the last list's cell
  • The cached sizeThatFits value

If those values differ sufficiently, the transaction is not committed and another layout pass is triggered. During a later pass, the real sizes become available and GeometryReader eventually reports the final correct value.

If this interpretation is correct, the behavior shown in the logs would be expected:

followed later by:

Possible solution

I could not find a public SwiftUI API that provides an accurate content size during the initial layout pass. I tried the following options:

  • LazyVStack + GeometryReader
  • LazyVStack + ViewThatFits
  • LazyVStack + .scrollBounceBehavior(...)

At the same time, SwiftUI itself appears to have information about layout validity.

For example, the layout logs contain entries such as: placed(...) -> ... invalid: true

This suggests that SwiftUI can determine when an estimated layout result is no longer valid and requires additional layout passes.

If SwiftUI knows that the current layout is invalid, is there a way to access this information from within a GeometryReader closure or by some other means?

Otherwise, clients may perform layout calculations based on invalid geometry, which can result in a broken dependent layout.

Have a good day!

Unexpected behavior in the interaction between LazyVStack and GeometryReader
 
 
Q