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:
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()mixescollectionView.frameandcollectionView.boundswhen computing item origins and sizes. Inside a layout object, useboundsconsistently —frameandboundscan diverge if the collection view has a non-identity transform, and yoursafeAreaInsetsoverride returning.zerocan also affect the relationship between the two. -
prepare(forAnimatedBoundsChange:)callsinvalidateLayout()when the width changes, but by the time this method is called the layout has already been invalidated through theshouldInvalidateLayout(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:)usescollectionView.frame.widthinstead ofbounds.width. Switching tobounds.widthhere keeps it consistent with the rest of the fix.