Rotation Support for Autosizing, Full-width UICollectionViewCells in Swift

This post extends the implementation from Autosizing, Full-width UICollectionViewCells in Swift to add rotation support to autosizing UICollectionViewCells. If you have not read the previous related post, please do so!

In the previous post, Autosizing, Full-width UICollectionViewCells in Swift 4, a UICollectionView extension was implemented to expose a computed variable widestCellWidth. To make a cell full width, widestCellWidth was used to define an estimateItemSize which was later using in systemLayoutSizeFitting.

However, on rotation the bounds of the collection view change and therefore widestCellWidth changes as well. Thus, to get started we need to remove the extension and any existing estimatedItemSize configuration so we can take a new approach.

Subclass UICollectionViewFlowLayout

The best way to implement rotation support is to subclass UICollectionViewFlowLayout and override shouldInvalidateLayout. Doing so provides a deterministic method for changing cell sizes on rotation without causing autolayout constraint errors or undefined UICollectionView behavior.

Other implementations, like overriding traitCollectionDidChange and viewWillTransition, often cause autolayout constraint errors or other error messages. This happens because the incorrect bounds are used to compute cell sizes. To properly support rotation, the new bounds after the rotation should be used to compute the estimatedItemSize.

UICollectionViewFlowLayout exposes exactly this api with the method shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool. Below is an implementation of a custom UICollectionViewFlowLayout that updates the estimateItemSize as needed on bounds changes:

class AutoInvalidatingLayout: UICollectionViewFlowLayout {
    // Compute the width of a full width cell 
    // for a given bounds
    func widestCellWidth(bounds: CGRect) -> CGFloat {
        guard let collectionView = collectionView else { 
            return 0 
        }

        let insets = collectionView.contentInset        
        let width = bounds.width - insets.left - insets.right
        
        if width < 0 { return 0 }
        else { return width }
    }
    
    // Update the estimatedItemSize for a given bounds
    func updateEstimatedItemSize(bounds: CGRect) {
        estimatedItemSize = CGSize(
            width: widestCellWidth(bounds: bounds),
            // Make the height a reasonable estimate to
            // ensure the scroll bar remains smooth 
            height: 200
        )
    }

    // assign an initial estimatedItemSize by calling 
    // updateEstimatedItemSize. prepare() will be called
    // the first time a collectionView is assigned
    override func prepare() {
        super.prepare()

        let bounds = collectionView?.bounds ?? .zero
        updateEstimatedItemSize(bounds: bounds)
    }
    
    // If the current collectionView bounds.size does 
    // not match newBounds.size, update the 
    // estimatedItemSize via updateEstimatedItemSize 
    // and invalidate the layout
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        guard let collectionView = collectionView else { 
            return false 
        }
        
        let oldSize = collectionView.bounds.size
        guard oldSize != newBounds.size else { return false }
        
        updateEstimatedItemSize(bounds: newBounds)
        return true
    }
}

Assign The Custom UICollectionViewFlowLayout

The last step is to assign an instance of AutoInvalidatingLayout as the collectionViewLayout on a collection view:

collectionView.collectionViewLayout = AutoInvalidatingLayout()

Results

Combined with the implementation from Autosizing, Full-width UICollectionViewCells in Swift, the new AutoInvalidatingLayout performs as expected during rotation changes with no error messages:

Portrait UICollectionViewCell layouts on an iPhone 8 simulator

Landscape UICollectionViewCell layouts on an iPhone 8 simulator