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:
RotationTestView.txt
4
0
62