SwiftUI List with Geometry header behavior changed after building app with Xcode 26

We have a custom implementation of what we call a “Scrollable Header” in our app. After building with Xcode 26, we’ve observed a change in behavior with the List component.

The issue can be seen in the attached GIF: As the user scrolls up, the header is expected to collapse smoothly, and it does—until the moment the next list item becomes visible. At that point, the header collapses prematurely, without any apparent reason.

We’ve identified that this behavior occurs after the list’s data-fetching logic runs, which loads additional items as the user scrolls.

Below is the ViewModifier responsible for handling the collapsing header logic:

@available(iOS 18.0, *)
public struct L3CollapseHeaderIOS18: ViewModifier {
    private let minHeight: Double = 0
    private let expandedHeight: CGFloat
    private let L3Height = 44.0
    private let isViewVisible: Bool

    @Binding private var currentHeight: CGFloat
    @State private var lastOffset: ScrollOffsetInfo = ScrollOffsetInfo(offset: 0.0, offsetToBottom: 0.0, scrollableContent: 0.0)

    init(currentHeight: Binding<CGFloat>, expectedHeight: CGFloat, isViewVisible: Bool) {
        self._currentHeight = currentHeight
        self.expandedHeight = expectedHeight
        self.isViewVisible = isViewVisible
    }

    public func body(content: Content) -> some View {
        content
            .onScrollGeometryChange(for: ScrollOffsetInfo.self, of: { geometry in
                if isViewVisible {
                    return ScrollOffsetInfo(offset: geometry.contentOffset.y, offsetToBottom: (geometry.contentSize.height) - (geometry.contentOffset.y + geometry.containerSize.height), scrollableContent: geometry.contentSize.height - geometry.containerSize.height)
                } else {
                    return lastOffset
                }

            }, action: { oldValue, newValue in
                if isViewVisible {
                    expandOrCollapseHeader(oldValue: oldValue, newValue: newValue)
                }
            })
    }

    private func expandOrCollapseHeader(oldValue: ScrollOffsetInfo, newValue: ScrollOffsetInfo) {
        let oldScrollableContent = round(oldValue.scrollableContent)
        let newScrollableContent = round(newValue.scrollableContent)
        print("@@ scrollable content: \(newScrollableContent), old value: \(oldScrollableContent)")

        if newScrollableContent != oldScrollableContent {/*abs(newValue.offset) - abs(oldValue.offset) > 80*/
            return
        }
        
        let isInitialPosition = newValue.offset == 0 && lastOffset.offset == 0
        let isTryingToBounceUp = newValue.offset < 0
        let isTryingToBounceDown = newValue.offsetToBottom < 0

        // As the header collapses, the scrollable content decreases its size
        let remainingHeaderSpaceVisible = expandedHeight - currentHeight
        // remainingHeaderSpaceVisible is summed to know the exact scrollableContent size
        let isScrollableContentSmallToAnimate = (newValue.scrollableContent + remainingHeaderSpaceVisible)  < (expandedHeight * 2 + currentHeight)

        if isInitialPosition || isScrollableContentSmallToAnimate {
            expandHeader(0)
            return
        }

        let scrollDirection = scrollDirection(newValue, oldOffset: oldValue)
        switch scrollDirection {
        case .up(let value):
            if isTryingToBounceUp {
                expandHeader(0)
                return
            }
            print("@@ will collapse with value: \(value)")
            collapseHeader(value)
        case .down(let value):
            
            if isTryingToBounceDown {
                collapseHeader(0)
                return
            }
            print("@@ will expand with value: \(value)")
            expandHeader(value)
        case .insignificant:
            return
        }
    }

    private func expandHeader(_ value: CGFloat) {
        currentHeight = min(68.0, currentHeight - value)
    }

    private func collapseHeader(_ value: CGFloat) {
        currentHeight = max(0, currentHeight - value)
    }

    private func scrollDirection(_ currentOffset: ScrollOffsetInfo, oldOffset: ScrollOffsetInfo) -> ScrollDirection {
        let scrollOffsetDifference = abs(currentOffset.offset) - abs(oldOffset.offset)
        print("@@ currentOffset: \(currentOffset.offset), oldOffset: \(oldOffset.offset), difference: \(scrollOffsetDifference)")
        let status: ScrollDirection = scrollOffsetDifference > 0
        ? .up(scrollOffsetDifference)
        : .down(scrollOffsetDifference)
        
        lastOffset = currentOffset
        return status
    }

    enum ScrollDirection {
        case up(CGFloat)
        case down(CGFloat)
        case insignificant
    }
}


public struct ScrollOffsetInfo: Equatable {
    public let offset: CGFloat
    public let offsetToBottom: CGFloat
    public let scrollableContent: CGFloat
}

public struct ScrollOffsetInfoPreferenceKey: PreferenceKey {
    public static var defaultValue: ScrollOffsetInfo = ScrollOffsetInfo(offset: 0, offsetToBottom: 0, scrollableContent: 0)

    public static func reduce(value: inout ScrollOffsetInfo, nextValue: () -> ScrollOffsetInfo) {
    }
}

We use this ViewModifier to monitor updates to the List’s frame via the onScrollGeometryChange method. From our investigation (see the screenshot below), it appears that onScrollGeometryChange is triggered just before the content size updates. The first event we receive corresponds to the view’s offset, and only nanoseconds later do we receive updates about the content’s scrollable height.

I’ll share the code for the List component using this modifier in the comments (it exceeded the character limit here).

Can anyone help us understand why this change in behavior occurs after building with Xcode 26?

Thanks in advance for any insights.

As promised, here is the SwiftUI view code that uses this modifier:

code-block
import SwiftUI

struct GridListView: View {
    @State private var paddingToTop: CGFloat = 68.0
    private let expandedPaddingToTop: CGFloat = 68.0
    private let paddingBetweenL2AndL3: CGFloat = 24.0
    private let L3Height: CGFloat = 44.0
    
    @State private var items: [Int] = Array(1...100)
    @State private var isLoading: Bool = false
    
    private var L3Opacity: CGFloat {
        pow(paddingToTop / expandedPaddingToTop, 5)
    }

    private var L3YOffset: CGFloat {
        -L3Height + (paddingToTop - paddingBetweenL2AndL3)
    }

    var body: some View {
        ZStack {
            List {
                // Show loaded items
                ForEach(Array(stride(from: 0, to: items.count, by: 2)), id: \.self) { index in
                    HStack {
                        GridItemView(item: items[index])
                        if index + 1 < items.count {
                            GridItemView(item: items[index + 1])
                        } else {
                            Spacer()
                        }
                    }
                    .listRowInsets(EdgeInsets())
                    .padding(.vertical, 8)
                    .onAppear {
                        // Detect when user reaches near the end
                        if index == items.count - 2 {
                            loadMoreItemsIfNeeded()
                        }
                    }
                }
                
                // Show placeholders while loading
                if isLoading {
                    ForEach(0..<500) { _ in
                        HStack {
                            GridItemPlaceholder()
                            GridItemPlaceholder()
                        }
                        .listRowInsets(EdgeInsets())
                        .padding(.vertical, 8)
                    }
                }
            }
            .padding(.top, max(paddingToTop, 8.0))
            .listStyle(.plain)
            .modifier(
                L3CollapseHeaderIOS18(
                    currentHeight: $paddingToTop,
                    expectedHeight: expandedPaddingToTop,
                    isViewVisible: true
                )
            )
        }
        .overlay(alignment: .top) {
            VStack {
                Text("Overlay Title")
                    .font(.headline)
                    .foregroundColor(.white)
            }
            .frame(maxWidth: .infinity, minHeight: 68.0)
            .background(Color.red)
            .opacity(L3Opacity)
            .offset(y: L3YOffset)
        }
    }
    
    private func loadMoreItemsIfNeeded() {
        guard !isLoading else { return }
        isLoading = true
        
        Task {
            // Simulate 2-second network delay
            try? await Task.sleep(nanoseconds: 2_000_000_000)
            
            // Add 1000 new items
            let nextItems = (items.count + 1)...(items.count + 1000)
            items.append(contentsOf: nextItems)
            
            isLoading = false
        }
    }
}

struct GridItemView: View {
    let item: Int
    
    var body: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 12)
                .fill(Color.blue.opacity(0.2))
                .frame(height: 50)
            
            Text("Item \(item)")
                .font(.headline)
        }
        .padding(.horizontal, 8)
    }
}

struct GridItemPlaceholder: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(Color.gray.opacity(0.2))
            .frame(height: 50)
            .redacted(reason: .placeholder)
            .padding(.horizontal, 8)
    }
}

#Preview {
    GridListView()
}
SwiftUI List with Geometry header behavior changed after building app with Xcode 26
 
 
Q