Embedded Collection View in SwiftUI offset issue

I have a collection view that covers all the screen and it is scrolling behavior is paging. This collection view is embedded in a UIViewRepresentable and used in a SwiftUI app.

The issue is that when users rotate the devices, sometimes the CollectionView.contentOffset get miscalculated and shows 2 pages.

This is the code that I'm using for the collectionView and collectionViewLayout:

    class PageFlowLayout: UICollectionViewFlowLayout {

        override class var layoutAttributesClass: AnyClass {
            UICollectionViewLayoutAttributes.self
        }

        private var calculatedAttributes: [UICollectionViewLayoutAttributes] = []
        private var calculatedContentWidth: CGFloat = 0
        private var calculatedContentHeight: CGFloat = 0

        public weak var delegate: PageFlowLayoutDelegate?

        override var collectionViewContentSize: CGSize {
            return CGSize(width: self.calculatedContentWidth, height: self.calculatedContentHeight)
        }

        override init() {
            super.init()
            self.estimatedItemSize = .zero
            self.scrollDirection = .horizontal
            self.minimumLineSpacing = 0
            self.minimumInteritemSpacing = 0
            self.sectionInset = .zero
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        override func prepare() {
            guard
                let collectionView = collectionView,
                collectionView.numberOfSections > 0,
                calculatedAttributes.isEmpty
            else { return }

            estimatedItemSize = collectionView.bounds.size
            for item in 0..<collectionView.numberOfItems(inSection: 0) {
                let indexPath = IndexPath(item: item, section: 0)
                let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
                let itemOrigin = CGPoint(x: CGFloat(item) * collectionView.frame.width, y: 0)
                attributes.frame = .init(origin: itemOrigin, size: collectionView.frame.size)
                calculatedAttributes.append(attributes)
            }
            calculatedContentWidth = collectionView.bounds.width * CGFloat(calculatedAttributes.count)
            calculatedContentHeight = collectionView.bounds.size.height
        }

        override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
            return calculatedAttributes.compactMap { return $0.frame.intersects(rect) ? $0 : nil }
        }

        override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
            return calculatedAttributes[indexPath.item]
        }

        override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
            guard let collectionView else { return false }

            if newBounds.size != collectionView.bounds.size {
                return true
            }
            if newBounds.size.width > 0 {
                let pages = calculatedContentWidth / newBounds.size.width
                // If the contentWidth matches the number of pages,
                // if not it requires to layout the cells
                let arePagesExact = pages.truncatingRemainder(dividingBy: 1) == 0
                return !arePagesExact
            }
            return false
        }

        override func invalidateLayout() {
            calculatedAttributes = []
            super.invalidateLayout()
        }

        override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
            guard let collectionView, #available(iOS 18.0, *) else { return false }
            return preferredAttributes.size != collectionView.bounds.size
        }

        override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
            guard let customContext = context as? UICollectionViewFlowLayoutInvalidationContext else { return }
            if let collectionView, let currentPage = delegate?.currentPage() {
                let delta = (CGFloat(currentPage) * collectionView.bounds.width) -  collectionView.contentOffset.x
                customContext.contentOffsetAdjustment.x += delta
            }
            calculatedAttributes = []
            super.invalidateLayout(with: customContext)
        }

        override func prepare(forAnimatedBoundsChange oldBounds: CGRect) {
            super.prepare(forAnimatedBoundsChange: oldBounds)
            guard let collectionView else { return }
            if oldBounds.width != collectionView.bounds.width {
                invalidateLayout()
            }
        }

        override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
            guard let collectionView, let currentPage = delegate?.currentPage() else { return .zero }
            let targetContentOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
            let targetPage = targetContentOffset.x / collectionView.frame.width
            if targetPage != CGFloat(currentPage) {
                let xPosition = CGFloat(currentPage) * collectionView.frame.width
                return CGPoint(x: xPosition, y: 0)
            }
            return targetContentOffset
        }

        // This function updates the contentOffset in case is wrong
        override func finalizeCollectionViewUpdates() {
            guard let collectionView, let currentPage = delegate?.currentPage() else { return }
            let xPosition = CGFloat(currentPage) * collectionView.bounds.width
            if xPosition != collectionView.contentOffset.x {
                let offset = CGPoint(x: xPosition, y: 0)
                collectionView.setContentOffset(offset, animated: false)
            }
        }
    }

The full implementation is attached in the .txt file:

Answered by DTS Engineer in 884594022

Your layout computes the content offset adjustment during invalidation using stale bounds, which causes the offset problem during rotation. Two issues contribute to this.

1. Stale bounds in invalidateLayout(with:)

Your invalidateLayout(with:) override computes the content offset adjustment using collectionView.bounds.width:

let delta = (CGFloat(currentPage) * collectionView.bounds.width) - collectionView.contentOffset.x
customContext.contentOffsetAdjustment.x += delta

The problem is that when this runs during a bounds change (rotation), collectionView.bounds.width may still reflect the pre-rotation width. The delta calculation snaps the offset back to where it already was, rather than adjusting for the new width.

Override invalidationContext(forBoundsChange:) instead — it receives the new bounds as a parameter:

override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
    let context = super.invalidationContext(forBoundsChange: newBounds)
    if let collectionView, let currentPage = delegate?.currentPage(), newBounds.width > 0 {
        let targetX = CGFloat(currentPage) * newBounds.width
        context.contentOffsetAdjustment.x = targetX - collectionView.contentOffset.x
    }
    return context
}

With this in place, you can remove the offset adjustment from invalidateLayout(with:) and simplify it to just clear the cached attributes and call super.

2. Preferred layout attributes triggering extra invalidation in iOS 18

Your cells use UIHostingConfiguration, which means they report preferred sizes through the self-sizing cell mechanism. Your shouldInvalidateLayout(forPreferredLayoutAttributes:withOriginalAttributes:) override returns true in iOS 18+ when the preferred size doesn't match the collection view bounds. During rotation, this triggers additional invalidation cycles that compound with the bounds-change invalidation, creating a brief interval during which the offset can land between pages.

Your layout already assigns each cell the full collection view bounds as its size, so the cells always match. Returning false unconditionally prevents the extra invalidation cascade:

override func shouldInvalidateLayout(
    forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes,
    withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes
) -> Bool {
    return false
}

Additional cleanup

Beyond the two main fixes, a few other things in the layout contribute to fragile offset handling during rotation:

  • prepare() mixes collectionView.frame and collectionView.bounds when computing item origins and sizes. Inside a layout object, use bounds consistently — frame and bounds can diverge if the collection view has a non-identity transform, and your safeAreaInsets override returning .zero can also affect the relationship between the two.

  • prepare(forAnimatedBoundsChange:) calls invalidateLayout() when the width changes, but by the time this method is called the layout has already been invalidated through the shouldInvalidateLayout(forBoundsChange:)invalidationContext(forBoundsChange:) path. The extra call clears the attributes a second time without contributing an offset adjustment.

  • finalizeCollectionViewUpdates() only runs after batch updates (insert/delete/move), not after bounds changes, so it doesn't correct the offset during rotation.

  • targetContentOffset(forProposedContentOffset:) uses collectionView.frame.width instead of bounds.width. Switching to bounds.width here keeps it consistent with the rest of the fix.

Your layout computes the content offset adjustment during invalidation using stale bounds, which causes the offset problem during rotation. Two issues contribute to this.

1. Stale bounds in invalidateLayout(with:)

Your invalidateLayout(with:) override computes the content offset adjustment using collectionView.bounds.width:

let delta = (CGFloat(currentPage) * collectionView.bounds.width) - collectionView.contentOffset.x
customContext.contentOffsetAdjustment.x += delta

The problem is that when this runs during a bounds change (rotation), collectionView.bounds.width may still reflect the pre-rotation width. The delta calculation snaps the offset back to where it already was, rather than adjusting for the new width.

Override invalidationContext(forBoundsChange:) instead — it receives the new bounds as a parameter:

override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
    let context = super.invalidationContext(forBoundsChange: newBounds)
    if let collectionView, let currentPage = delegate?.currentPage(), newBounds.width > 0 {
        let targetX = CGFloat(currentPage) * newBounds.width
        context.contentOffsetAdjustment.x = targetX - collectionView.contentOffset.x
    }
    return context
}

With this in place, you can remove the offset adjustment from invalidateLayout(with:) and simplify it to just clear the cached attributes and call super.

2. Preferred layout attributes triggering extra invalidation in iOS 18

Your cells use UIHostingConfiguration, which means they report preferred sizes through the self-sizing cell mechanism. Your shouldInvalidateLayout(forPreferredLayoutAttributes:withOriginalAttributes:) override returns true in iOS 18+ when the preferred size doesn't match the collection view bounds. During rotation, this triggers additional invalidation cycles that compound with the bounds-change invalidation, creating a brief interval during which the offset can land between pages.

Your layout already assigns each cell the full collection view bounds as its size, so the cells always match. Returning false unconditionally prevents the extra invalidation cascade:

override func shouldInvalidateLayout(
    forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes,
    withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes
) -> Bool {
    return false
}

Additional cleanup

Beyond the two main fixes, a few other things in the layout contribute to fragile offset handling during rotation:

  • prepare() mixes collectionView.frame and collectionView.bounds when computing item origins and sizes. Inside a layout object, use bounds consistently — frame and bounds can diverge if the collection view has a non-identity transform, and your safeAreaInsets override returning .zero can also affect the relationship between the two.

  • prepare(forAnimatedBoundsChange:) calls invalidateLayout() when the width changes, but by the time this method is called the layout has already been invalidated through the shouldInvalidateLayout(forBoundsChange:)invalidationContext(forBoundsChange:) path. The extra call clears the attributes a second time without contributing an offset adjustment.

  • finalizeCollectionViewUpdates() only runs after batch updates (insert/delete/move), not after bounds changes, so it doesn't correct the offset during rotation.

  • targetContentOffset(forProposedContentOffset:) uses collectionView.frame.width instead of bounds.width. Switching to bounds.width here keeps it consistent with the rest of the fix.

Embedded Collection View in SwiftUI offset issue
 
 
Q