Setcollectionviewlayout' Animation Broken When Also Changing Collection View Frame

`setCollectionViewLayout` animation broken when also changing collection view frame

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.

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

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.

Calling setCollectionViewLayout:animated does not reload UICollectionView

-setCollectionViewLayout: or -setCollectionViewLayout:animated: won't cause your UICollectionView reload its data.

  • If you want to change your cell style or update the data, call [self.collectionView reloadData] and UICollectionViewDataSource protocol methods will be called.

  • If you want to change your UICollectionView layout to another, call -setCollectionViewLayout:. Or if you want to update your UICollectionView layout, just call [self.collectionView.collectionViewLayout invalidateLayout].

Cell layout and data are two different things in UICollectionView.

Update
you should discard all the stale data, probably by -reloadData. And calculate the new frames for every cell in your UICollectionViewLayout subclasses. Override - (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath and - (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect.

You decide the new contentOffset. For instance, you could keep the indexPath of some visible cell and decide to scroll to that indexPath or setContentOffset: to the frame.origin of the new cell at the same indexPath after your layout changed.

  • And I find this answer may give you help.
  • Also check the awesome video WWDC 2012 Advanced Collection Views and Building Custom Layouts

Invisible Cell CollectionView Animation

Couple issues you're hitting here.

First, you're using a custom collection view layout to position the cells, but then you're also explicitly setting the cell y-origins.

Second, a collection view will only render the visible cells, so when you try to "un-expand" the layout, the bottom cell(s) don't exist.

I'm going to suggest a slightly different approach.

Use a protocol/delegate pattern so your custom UICollectionViewLayout can tell the controller to expand / collapse the layout when the pinch gesture occurs. The controller will then create a NEW instance of the custom layout and call .setCollectionViewLayout(...) - wrapped in an animation block - to either expand or collapse.

In addition, the controller will temporarily extend the height of the collection view so the "off-screen" cells will be rendered.

Here's some example code - I really made very few changes to your existing custom layout. The comments I included should be enough to make things clear.

Note, though, that this is Example Code Only -- it has not been thoroughly test and is intended to be a starting point:

protocol PinchProtocol: AnyObject {
func toggleExpanded(_ expand: Bool)
}

class MyWalletVC: UIViewController, PinchProtocol {

var data: [UIColor] = [
.red, .green, .blue, .cyan, .magenta,
//.yellow, .orange, .systemYellow,
]

var collectionView: UICollectionView!

var cvBottom: NSLayoutConstraint!

override func viewDidLoad() {
super.viewDidLoad()

let lay = WalletStackLayout()
lay.isExpanded = false
lay.pinchDelegate = self

collectionView = UICollectionView(frame: .zero, collectionViewLayout: lay)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)

let g = view.safeAreaLayoutGuide

cvBottom = collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor)

NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
cvBottom,
])

collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "c")
collectionView.dataSource = self
collectionView.delegate = self
}

func toggleExpanded(_ expand: Bool) {

// increase collection view height
// so "off-screen" cells will be rendered
// I just picked a value of 800 to make sure it's enough
self.cvBottom.constant = 800

UIView.animate(
withDuration: 0.5,
delay: 0,
options: .curveEaseOut,
animations: { [weak self] in
guard let self = self else { return }

// create a NEW layout object
let lay = WalletStackLayout()

// set its isExpanded property
lay.isExpanded = expand

// set self as its pinchDelegate
lay.pinchDelegate = self

// set the new layout
// use "animated: false" because we're animating it with UIView.animate
self.collectionView.setCollectionViewLayout(lay, animated: false)
},
completion: { [weak self] (_: Bool) in
guard let self = self else { return }
// reset collection view height
self.cvBottom.constant = 0
}
)

}

}

extension MyWalletVC: UICollectionViewDataSource, UICollectionViewDelegate {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return data.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: "c", for: indexPath)
c.contentView.backgroundColor = data[indexPath.item]
c.contentView.layer.cornerRadius = 16
return c
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
print("Selected item from Tap gesture:", indexPath)
}
}

typealias CellAndLayoutAttributes = (cell: UICollectionViewCell, layoutAttributes: UICollectionViewLayoutAttributes)

class WalletStackLayout: UICollectionViewLayout {

// so we can tell the controller we got pinched
weak var pinchDelegate: PinchProtocol?

// expanded / collapsed layout
var isExpanded: Bool = false

private let heightRatio: CGFloat = 196/343
private let sidePadding: CGFloat = 16.0
private let peekStack: CGFloat = 40

private var cellWidth: CGFloat {
return UIScreen.main.bounds.width - sidePadding * 2.0
//return Device.screenWidth - sidePadding*2
}

private var cellHeight: CGFloat {
return heightRatio * cellWidth
}

private var isMoving: Bool = false
private var collectionLayoutAttributes: [UICollectionViewLayoutAttributes] = []
private var tapGestureRecognizer: UITapGestureRecognizer?
private var pinchGestureRecognizer: UIPinchGestureRecognizer?

// this is needed to keep the Top cell at the Top of the collection view
// when changing the layout
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
return .zero
}

override var collectionViewContentSize: CGSize {

guard let collectionView = collectionView, collectionView.numberOfSections > 0 else {
return CGSize(width: 0, height: 0)
}

var contentHeight: CGFloat = 0
for index in 0..<collectionView.numberOfSections {
contentHeight += calculateSectionCardHeight(section: index)
}

return CGSize(
width: collectionView.bounds.width,
height: contentHeight
)
}

override func prepare() {
super.prepare()

collectionLayoutAttributes.removeAll()
guard let collectionView = collectionView, collectionView.numberOfSections > 0 else {
return
}

initializeCardCollectionViewLayout()

collectionLayoutAttributes = makeCardsLayoutAttributes()
}

override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {

guard let collectionView = collectionView, collectionView.numberOfSections > 0 else {
return nil
}

var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []

var r = rect
r.size.height += 500
for attributes in collectionLayoutAttributes where attributes.frame.intersects(r) {
visibleLayoutAttributes.append(attributes)
}

return visibleLayoutAttributes
}

override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return collectionLayoutAttributes[indexPath.row]
}

private func getCell(at indexPath: IndexPath) -> UICollectionViewCell? {
return collectionView?.cellForItem(at: indexPath)
}

private func getLayoutAttributes(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return collectionView?.layoutAttributesForItem(at: indexPath)
}

private func getCellAndLayoutAttributes(at indexPath: IndexPath) -> CellAndLayoutAttributes? {

guard let cell = getCell(at: indexPath),
let layoutAttributes = getLayoutAttributes(at: indexPath) else {
return nil
}

return (cell: cell, layoutAttributes: layoutAttributes)
}

// MARK: - BEGIN SET CARDS -
private func makeCardsLayoutAttributes() -> [UICollectionViewLayoutAttributes] {

guard let collectionView = collectionView, collectionView.numberOfSections > 0 else {
return []
}

var collectionViewLayout: [UICollectionViewLayoutAttributes] = []

for section in 0..<collectionView.numberOfSections {
for row in 0..<collectionView.numberOfItems(inSection: section) {
let indexPath = IndexPath(row: row, section: section)
collectionViewLayout.append(makeCardLayoutAttributes(forCellWith: indexPath))
}
}

return collectionViewLayout
}

private func makeInitialLayoutAttributes(forCellWith indexPath: IndexPath, height: CGFloat) -> UICollectionViewLayoutAttributes {

let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
let preferredSize = CGSize(width: cellWidth, height: height)
attributes.size = preferredSize

return attributes
}

private func makeCardLayoutAttributes(forCellWith indexPath: IndexPath) -> UICollectionViewLayoutAttributes {

let attributes = makeInitialLayoutAttributes(forCellWith: indexPath, height: cellHeight)
let coordinateY = calculateSectionYCoordinate(indexPath: indexPath)
attributes.frame.origin.y = coordinateY
attributes.frame.origin.x = sidePadding
attributes.zIndex = indexPath.item

return attributes
}

private func calculateSectionYCoordinate(indexPath: IndexPath) -> CGFloat {

var sectionYCoordinate: CGFloat = 0

for section in 0..<indexPath.section {
sectionYCoordinate += calculateSectionCardHeight(section: section)
}
if isExpanded {
return (cellHeight + sidePadding) * CGFloat(indexPath.row) + sectionYCoordinate
} else {
return peekStack * CGFloat(indexPath.row) + sectionYCoordinate
}
}

private func calculateSectionCardHeight(section: Int) -> CGFloat {

guard let numberOfItems = collectionView?.numberOfItems(inSection: section) else {
return 0
}

if isExpanded {
let totalExpandedCards: Int = numberOfItems
return (cellHeight + sidePadding) * CGFloat(totalExpandedCards)
} else {

let visibleCardCount: Int = 1
let totalStackedCards: Int = numberOfItems > 1 ? numberOfItems - visibleCardCount : 0

return peekStack * CGFloat(totalStackedCards) + cellHeight + sidePadding

}

}

// MARK: - TAP GESTURE -
private func initializeCardCollectionViewLayout() {

if tapGestureRecognizer == nil {
tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGestureHandler))
if let tapGesture = tapGestureRecognizer {
collectionView?.addGestureRecognizer(tapGesture)
}
}

if pinchGestureRecognizer == nil {
pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.handlePinchGesture(_:)))
pinchGestureRecognizer?.delegate = self
if let pinchGesture = pinchGestureRecognizer {
collectionView?.addGestureRecognizer(pinchGesture)
}
}
}

}

extension WalletStackLayout: UIGestureRecognizerDelegate {

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}

@objc private func handlePinchGesture(_ pinchGesture: UIPinchGestureRecognizer) {
if pinchGesture.state == .began || pinchGesture.state == .changed {

guard let collectionView = collectionView,
let tapLocation = pinchGestureRecognizer?.location(in: collectionView),
let indexPath = collectionView.indexPathForItem(at: tapLocation),
!isMoving else {
return
}

if pinchGesture.scale > 1 {
// tell the controller to switch to Expanded layout
pinchDelegate?.toggleExpanded(true)
} else if pinchGesture.scale < 1 {
// tell the controller to switch to Collapsed layout
pinchDelegate?.toggleExpanded(false)
}

}
}

@objc
private func tapGestureHandler() {
guard let collectionView = collectionView,
let tapLocation = tapGestureRecognizer?.location(in: collectionView),
let indexPath = collectionView.indexPathForItem(at: tapLocation) else {
return
}
print("TapGestureHandler Section: \(indexPath.section) Row: \(indexPath.row)")

collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath)
}

}

No @IBOutlet or @IBAction connections needed. Just assign the custom class of a standard view controller to MyWalletVC and it should run without problem.

Several animations in a collection view

Eventually, I managed to do it myself. In case it will be useful for anyone - one of the possible solutions is the following:

First, create a type of animation, for example var animationType = 1.

Then, in extension, make some cases:

extension TargetCollectionViewCell: ScaleTransformView {
var scaleOptions: ScaleTransformViewOptions {
if animationType == 1 {
return ScaleTransformViewOptions(
minScale: 0.01,
maxScale: 0.01,
scaleRatio: 0,
translationRatio: .zero,
minTranslationRatio: .zero,
maxTranslationRatio: .zero,
shadowEnabled: false,
rotation3d: .init(angle: 0.8, minAngle: -90, maxAngle: 90, x: 0, y: 1, z: 0, m34: -0.001),
translation3d: .init(translateRatios: (0, -1, 0), minTranslateRatios: (0, 0, 1.25), maxTranslateRatios: (0, 0, 1.25)))
} else if animationType == 2 {
return ScaleTransformViewOptions(
minScale: 0.04,
maxScale: 0.04,
scaleRatio: 0,
translationRatio: .zero,
minTranslationRatio: .zero,
maxTranslationRatio: .zero,
shadowEnabled: false,
rotation3d: .init(angle: 0.8, minAngle: -90, maxAngle: 90, x: 0, y: 1, z: 0, m34: -0.001),
translation3d: .init(translateRatios: (0, -1, 0), minTranslateRatios: (0, 0, 1.25), maxTranslateRatios: (0, 0, 1.25)))
} else {
return ScaleTransformViewOptions(
minScale: 0.07,
maxScale: 0.07,
scaleRatio: 0,
translationRatio: .zero,
minTranslationRatio: .zero,
maxTranslationRatio: .zero,
shadowEnabled: false,
rotation3d: .init(angle: 0.8, minAngle: -90, maxAngle: 90, x: 0, y: 1, z: 0, m34: -0.001),
translation3d: .init(translateRatios: (0, -1, 0), minTranslateRatios: (0, 0, 1.25), maxTranslateRatios: (0, 0, 1.25)))
}

return ScaleTransformViewOptions(

)
}
}


Related Topics



Leave a reply



Submit