Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

`setCollectionViewLayout` animation broken when also changing collection view frame

I'm trying to transition my collection view between a horizontal film-strip layout and a vertical stack layout.

Horizontal film strip Vertical/expanded stack
Cells horizontally aligned in a strip Cells vertically stacked

This is what I'm currently doing:

  1. Using setCollectionViewLayout(_:animated:completion:) to transition between horizontal and vertical scroll direction
  2. Changing the height constraint of the collection view, along with UIView.animate and self.view.layoutIfNeeded()

The animation from film-strip to vertical stack is fine. However, the animation from vertical stack to film-strip is broken. Here's what it looks like:

Transitioning between film-strip and expanded stack, but glitched when going back

If I make collectionViewHeightConstraint 300 by default, and remove these lines:

collectionViewHeightConstraint.constant = 300
collectionViewHeightConstraint.constant = 50

... the transition animation is fine both ways. However, there is excess spacing, and I want the film-strip layout to only be in 1 row.

Transitioning between film-strip and expanded stack, not glitched, but the film-strip layout has 5 rows and 2 columns instead of 1 row

How can I make the animation be smooth both ways? Here's my code (link to the demo project):

class ViewController: UIViewController {

    var isExpanded = false
    var verticalFlowLayout = UICollectionViewFlowLayout()
    var horizontalFlowLayout = UICollectionViewFlowLayout()
    
    @IBOutlet weak var collectionView: UICollectionView!
    @IBOutlet weak var collectionViewHeightConstraint: NSLayoutConstraint!
    @IBAction func toggleExpandPressed(_ sender: Any) {
        
        isExpanded.toggle()
        if isExpanded {
            collectionView.setCollectionViewLayout(verticalFlowLayout, animated: true) /// set vertical scroll
            collectionViewHeightConstraint.constant = 300 /// make collection view height taller
        } else {
            collectionView.setCollectionViewLayout(horizontalFlowLayout, animated: true) /// set horizontal scroll
            collectionViewHeightConstraint.constant = 50 /// make collection view height shorter
        }
        
        /// animate the collection view's height
        UIView.animate(withDuration: 1) {
            self.view.layoutIfNeeded()
        }
        
        /// Bonus points:
        /// This makes the animation way more worse, but I would like to be able to scroll to a specific IndexPath during the transition.
        //  collectionView.scrollToItem(at: IndexPath(item: 9, section: 0), at: .bottom, animated: true)
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        verticalFlowLayout.scrollDirection = .vertical
        horizontalFlowLayout.scrollDirection = .horizontal
        
        collectionView.collectionViewLayout = horizontalFlowLayout
        collectionView.dataSource = self
        collectionView.delegate = self
    }
}

extension ViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        
        /// if expanded, cells should be full-width
        /// if not expanded, cells should have a width of 50
        return isExpanded ? CGSize(width: collectionView.frame.width, height: 50) : CGSize(width: 100, height: 50)
    }
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 0 /// no spacing needed for now
    }
}

/// sample data source
extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 10
    }
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ID", for: indexPath)
        cell.contentView.layer.borderWidth = 5
        cell.contentView.layer.borderColor = UIColor.red.cgColor
        return cell
    }
}
like image 804
aheze Avatar asked Jun 21 '21 04:06

aheze


3 Answers

I got it working by using a custom UICollectionViewFlowLayout class (link to demo repo). The center cell even stays centered across both layouts (which was actually the exact purpose of the "Bonus points" part in my question)!

Cells animate and transition between film-strip and vertical stack smoothly

Here's my view controller. Instead of conforming to UICollectionViewDelegateFlowLayout, I now use the custom closures sizeForListItemAt and sizeForStripItemAt.

class ViewController: UIViewController {

    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)
            collectionViewHeightConstraint.constant = 300
        } else {
            collectionView.setCollectionViewLayout(stripLayout, animated: true)
            collectionViewHeightConstraint.constant = 60
        }
        UIView.animate(withDuration: 0.6) {
            self.view.layoutIfNeeded()
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.collectionViewLayout = stripLayout
        collectionView.dataSource = self
        
        /// use these instead of `UICollectionViewDelegateFlowLayout`
        listLayout.sizeForListItemAt = { [weak self] indexPath in
            return CGSize(width: self?.collectionView.frame.width ?? 100, height: 50)
        }
        stripLayout.sizeForStripItemAt = { indexPath in
            return CGSize(width: 100, height: 50)
        }
    }
}

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

Then, here's my custom UICollectionViewFlowLayout class. Inside prepare, I manually calculate and set the frames of each cell. I'm not exactly sure why this works, but the system is now able to figure out which cells are equal to which, even across multiple FlowLayouts (listLayout and stripLayout.).

enum LayoutType {
    case list
    case strip
}

class FlowLayout: UICollectionViewFlowLayout {

    var layoutType: LayoutType
    var sizeForListItemAt: ((IndexPath) -> CGSize)? /// get size for list item
    var sizeForStripItemAt: ((IndexPath) -> CGSize)? /// get size for strip item
    
    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
    
    override func prepare() { /// configure the cells' frames
        super.prepare()
        
        guard let collectionView = collectionView else { return }
        let itemCount = collectionView.numberOfItems(inSection: 0) /// I only have 1 section
        
        if layoutType == .list {
            var y: CGFloat = 0 /// y position of each cell, start at 0
            for itemIndex in 0..<itemCount {
                let indexPath = IndexPath(item: itemIndex, section: 0)
                let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
                attributes.frame = CGRect(
                    x: 0,
                    y: y,
                    width: sizeForListItemAt?(indexPath).width ?? 0,
                    height: sizeForListItemAt?(indexPath).height ?? 0
                )
                layoutAttributes.append(attributes)
                y += attributes.frame.height /// add height to y position, so next cell becomes offset
            }                           /// use first item's width
            contentSize = CGSize(width: sizeForStripItemAt?(IndexPath(item: 0, section: 0)).width ?? 0, height: y)
        } else {
            var x: CGFloat = 0 /// z position of each cell, start at 0
            for itemIndex in 0..<itemCount {
                let indexPath = IndexPath(item: itemIndex, section: 0)
                let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
                attributes.frame = CGRect(
                    x: x,
                    y: 0,
                    width: sizeForStripItemAt?(indexPath).width ?? 0,
                    height: sizeForStripItemAt?(indexPath).height ?? 0
                )
                layoutAttributes.append(attributes)
                x += attributes.frame.width /// add width to z position, so next cell becomes offset
            }                              /// use first item's height
            contentSize = CGSize(width: x, height: sizeForStripItemAt?(IndexPath(item: 0, section: 0)).height ?? 0)
        }
    }
    
    /// 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) }
    }

    /// initialize with a layout
    init(layoutType: LayoutType) {
        self.layoutType = layoutType
        super.init()
    }
    
    /// 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
    }
}

Thanks to this amazing article for the huge help.

like image 120
aheze Avatar answered Oct 11 '22 02:10

aheze


The problem is both the animation changing layout and changing height colliding with each other since the duration of animation is different. I suggest the following changes

UIView.animate(withDuration: self.isExpanded ? 0.3 : 0.5, delay: 0.0, options: self.isExpanded ? .curveEaseIn : .curveEaseOut) {
  if self.isExpanded {
    self.collectionView.setCollectionViewLayout(self.verticalFlowLayout, animated: false) /// set vertical scroll
    self.collectionViewHeightConstraint.constant = 300 /// make collection view height taller
  } else{
    self.collectionView.setCollectionViewLayout(self.horizontalFlowLayout, animated: false) /// set horizontal scroll
    self.collectionViewHeightConstraint.constant = 50 /// make collection view height shorter
  }
   self.view.layoutIfNeeded()
} completion: { (completed) in
    self.collectionView.reloadData()
}
like image 40
iphonic Avatar answered Oct 11 '22 03:10

iphonic


You can fix your animation with this code. Also, set delay for self.collectionView.scrollToItem (Set delay value same as animation duration)

@IBAction func toggleExpandPressed(_ sender: Any) {
    
    isExpanded.toggle()
    
    if isExpanded {
        collectionView.setCollectionViewLayout(verticalFlowLayout, animated: true) /// set vertical scroll
        collectionViewHeightConstraint.constant = 300 /// make collection view height taller
        UIView.animate(withDuration: 1) {
            self.view.layoutIfNeeded()
        }
        
    } else {
        collectionViewHeightConstraint.constant = 50 /// make collection view height shorter
        UIView.animate(withDuration: 1) { [self] in
            self.view.layoutIfNeeded()
            collectionView.setCollectionViewLayout(horizontalFlowLayout, animated: false) /// set horizontal scroll
        }
    }
    
    /// Bonus points:
    /// This makes the animation way more worse, but I would like to be able to scroll to a specific IndexPath during the transition.
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        self.collectionView.scrollToItem(at: IndexPath(item: self.isExpanded ? 9 : 5, section: 0), at: self.isExpanded ? .centeredVertically : .centeredHorizontally, animated: true)
    }
    
}

enter image description here

like image 1
Raja Kishan Avatar answered Oct 11 '22 03:10

Raja Kishan