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.