Post

Replies

Boosts

Views

Activity

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.
3
2
146
Oct ’25