'How to animate collection view layout change while using `layoutAttributesForElements`?

I made a custom collection view flow layout that can toggle (with animation) between "film-strip" and "list" layouts. But after adding some fancy animations to the edge cells, the toggle animation broke. Here's what it looks like currently, without those changes:

Toggling between film strip and list mode with animation

The animation is nice and smooth, right? Here's the current, working code (full demo project here):

enum LayoutType {
    case strip
    case list
}

class FlowLayout: UICollectionViewFlowLayout {
    
    var layoutType: LayoutType
    var layoutAttributes = [UICollectionViewLayoutAttributes]() /// store the frame of each item
    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]
    }
    
    // MARK: - Problem is here
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        
        /// edge cells don't shrink, but the animation is perfect
        return layoutAttributes.filter { rect.intersects($0.frame) } /// try deleting this line
        
        /// edge cells shrink (yay!), but the animation glitches out
        return shrinkingEdgeCellAttributes(in: rect)
    }
    
    /// makes the edge cells slowly shrink as you scroll
    func shrinkingEdgeCellAttributes(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let collectionView = collectionView else { return nil }

        let rectAttributes = layoutAttributes.filter { rect.intersects($0.frame) }
        let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size) /// rect of the visible collection view cells

        let leadingCutoff: CGFloat = 50 /// once a cell reaches here, start shrinking it
        let trailingCutoff: CGFloat
        let paddingInsets: UIEdgeInsets /// apply shrinking even when cell has passed the screen's bounds

        if layoutType == .strip {
            trailingCutoff = CGFloat(collectionView.bounds.width - leadingCutoff)
            paddingInsets = UIEdgeInsets(top: 0, left: -50, bottom: 0, right: -50)
        } else {
            trailingCutoff = CGFloat(collectionView.bounds.height - leadingCutoff)
            paddingInsets = UIEdgeInsets(top: -50, left: 0, bottom: -50, right: 0)
        }

        for attributes in rectAttributes where visibleRect.inset(by: paddingInsets).contains(attributes.center) {
            /// center of each cell, converted to a point inside `visibleRect`
            let center = layoutType == .strip
                ? attributes.center.x - visibleRect.origin.x
                : attributes.center.y - visibleRect.origin.y

            var offset: CGFloat?
            if center <= leadingCutoff {
                offset = leadingCutoff - center /// distance from the cutoff, 0 if exactly on cutoff
            } else if center >= trailingCutoff {
                offset = center - trailingCutoff
            }

            if let offset = offset {
                let scale = 1 - (pow(offset, 1.1) / 200) /// gradually shrink the cell
                attributes.transform = CGAffineTransform(scaleX: scale, y: scale)
            }
        }
        return rectAttributes
    }
    
    /// initialize with a LayoutType
    init(layoutType: LayoutType) {
        self.layoutType = layoutType
        super.init()
    }
    
    /// make the layout (strip vs list) here
    override func prepare() { /// configure the cells' frames
        super.prepare()
        guard let collectionView = collectionView else { return }
        
        var offset: CGFloat = 0 /// origin for each cell
        let cellSize = layoutType == .strip ? CGSize(width: 100, height: 50) : CGSize(width: collectionView.frame.width, height: 50)
        
        for itemIndex in 0..<collectionView.numberOfItems(inSection: 0) {
            let indexPath = IndexPath(item: itemIndex, section: 0)
            let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
            
            let origin: CGPoint
            let addedOffset: CGFloat
            if layoutType == .strip {
                origin = CGPoint(x: offset, y: 0)
                addedOffset = cellSize.width
            } else {
                origin = CGPoint(x: 0, y: offset)
                addedOffset = cellSize.height
            }
            
            attributes.frame = CGRect(origin: origin, size: cellSize)
            layoutAttributes.append(attributes)
            offset += addedOffset
        }
        
        self.contentSize = layoutType == .strip /// set the collection view's `collectionViewContentSize`
            ? CGSize(width: offset, height: cellSize.height) /// if strip, height is fixed
            : CGSize(width: cellSize.width, height: offset) /// if list, width is fixed
    }
    
    /// boilerplate code
    required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool { return true }
    override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
        let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
        context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
        return context
    }
}
class ViewController: UIViewController {
    
    var data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    
    var isExpanded = false
    lazy var listLayout = FlowLayout(layoutType: .list)
    lazy var stripLayout = FlowLayout(layoutType: .strip)
    
    @IBOutlet weak var collectionView: UICollectionView!
    @IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint!
    @IBAction func toggleExpandPressed(_ sender: Any) {
        isExpanded.toggle()
        if isExpanded {
            collectionView.setCollectionViewLayout(listLayout, animated: true)
        } else {
            collectionView.setCollectionViewLayout(stripLayout, animated: true)
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.collectionViewLayout = stripLayout /// start with the strip layout
        collectionView.dataSource = self
        collectionViewHeightConstraint.constant = 300
    }
}

/// sample data source
extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return data.count
    }
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ID", for: indexPath) as! Cell
        cell.label.text = "\(data[indexPath.item])"
        cell.contentView.layer.borderWidth = 5
        cell.contentView.layer.borderColor = UIColor.red.cgColor
        return cell
    }
}

class Cell: UICollectionViewCell {
    @IBOutlet weak var label: UILabel!
}

Again, everything works perfectly, including the animation. So then, I tried to make the cells shrink as they neared the screen's edge. I overrode layoutAttributesForElements to do this.

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    return layoutAttributes.filter { rect.intersects($0.frame) } /// delete this line
    return shrinkingEdgeCellAttributes(in: rect) /// replace with this
}
Film-strip List
Edge cells shrink when scrolling horizontally Edge cells shrink when scrolling vertically

The scale/shrink animation is great. However, when I toggle between the layouts, the transition animation is broken.

Before (return layoutAttributes.filter...) After (return shrinkingEdgeCellAttributes(in: rect))
Toggling between film strip and list mode with smooth animation Toggling between film strip and list mode, animation is very broken

How can I fix this animation? Should I be using a custom UICollectionViewTransitionLayout, and if so, how?



Solution 1:[1]

Whew! This was a workout. I was able to modify your FlowLayout so that there are no hiccups in animation. See below.

It works!

Problem

This is what was happening. When you change layouts, the layoutAttributesForElements method in FlowLayout is called twice if the content offset of the collection view is anything but (0, 0).

This is because you have overridden 'shouldInvalidateLayout' to return true regardless of whether it is actually needed. I believe the UICollectionView calls this method on the layout before and after the layout change (as per the observation).

The side effect of this is that your scale transform is applied twice - before and after the animations to the visible layout attributes.

Unfortunately, the scale transform is applied based on the contentOffset of the collection view (link)

let visibleRect = CGRect(
    origin: collectionView.contentOffset, 
    size: collectionView.frame.size
)

During layout changes the contentOffset is not consistent. Before the animation starts contentOffset is applicable to the previous layout. After the animation, it is relative to the new layout. Here I also noticed that without a good reason, the contentOffset "jumps" around (see note 1)

Since you use the visibleRect to query the set of Layout Attributes to apply the scale on, it introduces further errors.

Solution

I was able to find a solution by applying these changes.

  1. Write helpers methods to transform the content offset (and dependent visibleRect) left by the previous layout to values meaningful for this layout.
  2. Prevent redundant layout attribute calculates in prepare method
  3. Track when and when not the layout is animating
// In Flow Layout

class FlowLayout: UICollectionViewFlowLayout {
    var animating: Bool = false
    // ...
}

// In View Controller,

isExpanded.toggle()
        
if isExpanded {
    listLayout.reset()
    listLayout.animating = true // <--
    // collectionView.setCollectionViewLayout(listLayout)
} else {
    stripLayout.reset()
    stripLayout.animating = true // <--
    // collectionView.setCollectionViewLayout(stripLayout)
}
  1. Override targetContentOffset method to handle content offset changes (prevent jumps)
// In Flow Layout

class FlowLayout: UICollectionViewFlowLayout {
    
    var animating: Bool = false
    var layoutType: LayoutType
    // ...
    
    override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
        guard animating else {
            // return super
        }

        // Use our 'graceful' content content offset
        // instead of arbitrary "jump"
        
        switch(layoutType){
        case .list: return transformCurrentContentOffset(.fromStripToList)
        case .strip: return transformCurrentContentOffset(.fromListToStrip)
        }
    }

// ...

The implementation of content offset transforming is as follows.

/**
 Transforms this layouts content offset, to the other layout
 as specified in the layout transition parameter.
*/
private func transformCurrentContentOffset(_ transition: LayoutTransition) -> CGPoint{
    
    let stripItemWidth: CGFloat = 100.0
    let listItemHeight: CGFloat = 50.0
    
    switch(transition){
    case .fromStripToList:
        let numberOfItems = collectionView!.contentOffset.x / stripItemWidth  // from strip
        var newPoint = CGPoint(x: 0, y: numberOfItems * CGFloat(listItemHeight)) // to list

        if (newPoint.y + collectionView!.frame.height) >= contentSize.height{
            newPoint = CGPoint(x: 0, y: contentSize.height - collectionView!.frame.height)
        }

        return newPoint

    case .fromListToStrip:
        let numberOfItems = collectionView!.contentOffset.y / listItemHeight // from list
        var newPoint = CGPoint(x: numberOfItems * CGFloat(stripItemWidth), y: 0) // to strip

        if (newPoint.x + collectionView!.frame.width) >= contentSize.width{
            newPoint = CGPoint(x: contentSize.width - collectionView!.frame.width, y: 0)
        }

        return newPoint
    }
}

There are some minor details I left out in the comments and as a pull request to OP's demo project so anyone interested can study it.

The key take-aways are,

  • Use targetContentOffset when arbitrary changes in content offset occur in response to layout changes.

  • Be careful about incorrect query of layout attributes in layoutAttributesForElements. Debug your rects!

  • Remember to clear your cached layout attributes on the prepare() method.

Notes

  1. The "jump" behavior is evident even before you introduced scale transforms as seen in your gif.

  2. I sincerely apologize if the answer is lengthy. Or, The solution is not quite what you wanted. The question was interesting which is why I spent the whole day trying to find a way to help.

  3. Fork and Pull request.

Solution 2:[2]

Thanks for your detailed investigation @Thisura Dodangoda – it was instrumental in helping me solve a similar problem. For folks who end up here, I want to add a tiny detail in case you run into another issue that I did. The UICollectionViewLayout API has 2 very similar methods:

func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint

This method Retrieves the point at which to stop scrolling and

func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint

This method Retrieves the content offset to use after an animated layout update or change

I had already implemented the first for some custom behaviour during scrolling, and I was trying to implement the solution posted by @Thisura Dodangoda in that method. However, these are used for completely different purposes. You need to use the second method (without the velocity parameter) to implement the solution for layout changes.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 NomNom