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 |
---|---|
This is what I'm currently doing:
setCollectionViewLayout(_:animated:completion:)
to transition between horizontal and vertical scroll directionUIView.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:
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.
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
}
}
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)!
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 FlowLayout
s (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.
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()
}
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)
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With