Search code examples
iosswiftuicollectionviewuicollectionviewlayout

UICollectionViewFlowLayout `targetContentOffset` - how to keep cells centered after orientation change?


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
Swiping on cells centers each cell Rotating the devices makes the cells get off center. But as soon as you swipe, they snap back.

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:)?


Solution

  • 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.

    Important!

    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 change
    • targetContentOffset(forProposedContentOffset:withScrollingVelocity:) is called when the user lifts their finger

    So, 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.