I have a custom UICollectionViewFlowLayout that centers each cell. To do this, I overrode targetContentOffset
, which the collection view calls whenever the user lifts up their finger. However, once I rotate the device, the cells get off center — targetContentOffset
is not called.
Normally, cells are centered | After rotating the device, cells are no longer centered |
---|---|
![]() |
![]() |
Note 1: After rotating, just swipe a bit on the cells, and they bounce back to center...
Note 2: This gets printed in my console:
2021-11-16 21:37:54.979021-0800 TargetContentOffsetTest[30817:356789] [UICollectionViewRecursion] cv == 0x12f02d400 Disabling recursion trigger logging
Here's my code (demo repo):
class PagingFlowLayout: UICollectionViewFlowLayout {
var layoutAttributes = [UICollectionViewLayoutAttributes]() /// custom attributes
var contentSize = CGSize.zero /// the scrollable content size of the collection view
override var collectionViewContentSize: CGSize { return contentSize } /// pass scrollable content size back to the collection view
/// pass attributes to the collection view flow layout
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { return layoutAttributes[indexPath.item] }
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { return layoutAttributes.filter { rect.intersects($0.frame) } }
override func prepare() {
super.prepare()
guard let collectionView = collectionView else { return }
let cellWidth = collectionView.bounds.width
let cellHeight = collectionView.bounds.height
var layoutAttributes = [UICollectionViewLayoutAttributes]()
var currentCellOrigin = CGFloat(0) /// used for each cell's origin
for index in 0..<3 { /// hardcoded, but only for now
let attributes = UICollectionViewLayoutAttributes(forCellWith: IndexPath(item: index, section: 0))
attributes.frame = CGRect(x: currentCellOrigin, y: 0, width: cellWidth, height: cellHeight)
layoutAttributes.append(attributes)
currentCellOrigin += cellWidth
}
self.contentSize = CGSize(width: currentCellOrigin, height: cellHeight)
self.layoutAttributes = layoutAttributes
}
/// center the cell
/// this is called when the finger lifts, but NOT when the device rotates!
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
let contentOffset = collectionView?.contentOffset.x ?? 0
let closestPoint = layoutAttributes.min { abs($0.frame.origin.x - contentOffset) < abs($1.frame.origin.x - contentOffset) }
return closestPoint?.frame.origin ?? proposedContentOffset
}
}
class ViewController: UIViewController, UICollectionViewDataSource {
lazy var collectionView: UICollectionView = {
let flowLayout = PagingFlowLayout()
flowLayout.scrollDirection = .horizontal
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.decelerationRate = .fast
collectionView.dataSource = self
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leftAnchor.constraint(equalTo: view.leftAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
collectionView.rightAnchor.constraint(equalTo: view.rightAnchor)
])
return collectionView
}()
let colors: [UIColor] = [.red, .green, .blue]
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { return 3 }
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
cell.contentView.backgroundColor = colors[indexPath.item]
return cell
}
override func viewDidLoad() {
super.viewDidLoad()
_ = collectionView /// setup
}
}
How can I make the collection view call targetContentOffset
after a bounds change/rotation event? What is the correct way to ensure my cells always stay centered — is there something automatic I can use, or should I subscribe to viewDidLayoutSubviews
back in my view controller and manually call setContentOffset(_:animated:)
?
As far as I can see, the method is getting called not when you just rotate the device, but when the layout changes. Meaning, if you change from Landscape Left
to Landscape Right
the delegate method is not called — however if you rotating from any Landscape
to Portrait
or other way around, it is working fine.
For maintaining the centered collection view cell, add the targetContentOffset(forProposedContentOffset:)
method. This is called after a rotation, and is not the same as your current targetContentOffset(forProposedContentOffset:withScrollingVelocity:)
method. In your final code, you should use both of these methods. To summarize:
targetContentOffset(forProposedContentOffset:)
is called after a layout changetargetContentOffset(forProposedContentOffset:withScrollingVelocity:)
is called when the user lifts their fingerSo, in your PagingFlowLayout
class, paste the following code:
private var focusedIndexPath: IndexPath?
override func prepare(forAnimatedBoundsChange oldBounds: CGRect) {
super.prepare(forAnimatedBoundsChange: oldBounds)
focusedIndexPath = collectionView?.indexPathsForVisibleItems.first
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
guard
let indexPath = focusedIndexPath,
let attributes = layoutAttributesForItem(at: indexPath),
let collectionView = collectionView
else {
return super.targetContentOffset(forProposedContentOffset: proposedContentOffset)
}
return CGPoint(
x: attributes.frame.origin.x - collectionView.contentInset.left,
y: attributes.frame.origin.y - collectionView.contentInset.top
)
}
override func finalizeAnimatedBoundsChange() {
super.finalizeAnimatedBoundsChange()
focusedIndexPath = nil
}
The code was from: https://stackoverflow.com/a/54868999/11332605.