I'm trying to achieve a UICollectionView layout that works like a flow layout, but can conditionally stack multiple items of the grid into a single cell.
Right now I'm using a helper struct, AssetStack
, to achieve this. Each grid item displays one of these stacks, even if it's just a single item in the stack. The problem is that this makes datasource updates tricky as section counts change, and it mixes a lot of layout logic into the data structure. It also means I can't animate the transition easily.
It seems like a custom flow layout or compositional layout could do this, but I'm not sure how, and rather than spending another month on APIs that don't turn out to do what I need, I thought I'd ask the experts here.
Thanks y'all
There are a lot of unknown parts to this, including ...
13, 14, 16, 17
are all selected, should 16
and 17
move from section 2
to section 1
?and so on.
But, let's start with the basics and see if you can go from there.
With a custom UICollectionViewLayout
, we generate an array of frames for the collection view to use for its cells - much in the way we would define view frames.
Creating a custom "grid" (think vertical flow layout) is fairly straightforward (this is not the actual code -- just explaining the logic):
// first cell is at 0,0
var cellFrame: CGRect = .init(x: 0.0, y: 0.0, width: itemSize.width, height: itemSize.height)
for i in 0..<theCells.count {
let indexPath: IndexPath = .init(item: i, section: 0)
theCells[indexPath.item].frame = cellFrame
// increment frame x by item width + spacing
cellFrame.origin.x += itemSize.width + itemSpacing
if cellFrame.origin.x > simCollectionView.bounds.width {
// we've exceeded the width, so "wrap around" to the next row
cellFrame.origin.x = 0.0
cellFrame.origin.y += itemSize.height + rowSpacing
}
}
and it looks like this:
Now, suppose we select some cells:
If we want to "collapse" (or "stack") the consecutive selected cells, we can modify our logic slightly by saying:
So, our loop looks like this:
var cellFrame: CGRect = .init(x: 0.0, y: 0.0, width: itemSize.width, height: itemSize.height)
for i in 0..<theCells.count {
let indexPath: IndexPath = .init(item: i, section: 0)
theCells[indexPath.item].frame = cellFrame
// index path for the next cell
let ipNext: IndexPath = .init(item: i+1, section: 0)
// if we want the consecutive selected cells to "collapse"
// if current cell is selected AND the next cell is selected
if isCollapsed, selectedPaths.contains(indexPath), selectedPaths.contains(ipNext) {
// don't change frame
} else {
cellFrame.origin.x += itemSize.width + itemSpacing
if cellFrame.origin.x > simCollectionView.bounds.width {
cellFrame.origin.x = 0.0
cellFrame.origin.y += itemSize.height + rowSpacing
}
}
}
and we get this output:
So, we can define one array of cell frames "un-collapsed" and a second array "collapsed" -- and then tell the collection view update its layout inside an animation block:
UIView.animate(withDuration: 0.3, animations: {
self.layout.invalidateLayout()
self.collectionView.layoutIfNeeded()
})
In your comments, you want to "fine-tune" the stacking animation... since collection view cells are views we can animated them individually, then generate the new layout and animate the rest of the cells.
Here is a complete example you can play with...
Simple Cell Class
class SimpleCell: UICollectionViewCell {
class var reuseIdentifier: String { return "\(self)" }
var label: UILabel = UILabel()
let unselectedColor: UIColor = .init(white: 0.9, alpha: 1.0)
let selectedColor: UIColor = .init(white: 0.75, alpha: 1.0)
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
label.font = UIFont.systemFont(ofSize: 16)
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
let g = contentView
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: g.centerXAnchor),
label.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
contentView.backgroundColor = unselectedColor
}
override var isSelected: Bool {
didSet {
contentView.backgroundColor = isSelected ? selectedColor : unselectedColor
}
}
}
Custom Layout Class
class CollapsibleGridLayout: UICollectionViewLayout {
public var itemSize: CGSize = .init(width: 50.0, height: 50.0)
public var itemSpacing: CGFloat = 8.0
public var rowSpacing: CGFloat = 8.0
public var isCollapsed: Bool = false
private var previousAttributes: [UICollectionViewLayoutAttributes] = []
private var currentAttributes: [UICollectionViewLayoutAttributes] = []
private var contentSize: CGSize = .zero
override func prepare() {
super.prepare()
previousAttributes = currentAttributes
contentSize = .zero
currentAttributes = []
if let collectionView = collectionView {
let pths: [IndexPath] = collectionView.indexPathsForSelectedItems ?? []
var cellFrame: CGRect = .init(x: 0.0, y: 0.0, width: itemSize.width, height: itemSize.height)
let itemCount = collectionView.numberOfItems(inSection: 0)
for i in 0..<itemCount {
let indexPath: IndexPath = .init(item: i, section: 0)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = cellFrame
currentAttributes.append(attributes)
let ipNext: IndexPath = .init(item: i+1, section: indexPath.section)
// if we want the consecutive selected cells to "collapse"
// if current cell is selected AND the next cell is selected
if isCollapsed, pths.contains(indexPath), pths.contains(ipNext) {
// don't change frame
} else {
cellFrame.origin.x += itemSize.width + itemSpacing
if cellFrame.origin.x > collectionView.bounds.width {
cellFrame.origin.x = 0.0
cellFrame.origin.y += itemSize.height + rowSpacing
}
}
}
// if cellFrame.origin.x is currently 0, that means we've wrapped to a new row
// but we have no cells on that row
let csHeight: CGFloat = cellFrame.origin.x == 0 ? cellFrame.minY - rowSpacing : cellFrame.maxY
contentSize = .init(width: collectionView.bounds.width, height: csHeight)
}
}
// MARK: - Layout Attributes
override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return previousAttributes[itemIndexPath.item]
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return currentAttributes[indexPath.item]
}
override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return layoutAttributesForItem(at: itemIndexPath)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
return currentAttributes.filter { rect.intersects($0.frame) }
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
if let cv = collectionView, cv.bounds != newBounds {
return true
}
return false
}
override var collectionViewContentSize: CGSize { return contentSize }
}
Data Object struct - assuming we'd have complex cells...
struct MyObject {
var myID: Int = 0
}
Example Controller Class
class MyViewController: UIViewController {
var myData: [MyObject] = []
var collectionView: UICollectionView!
var layout: CollapsibleGridLayout = CollapsibleGridLayout()
var segCtrl: UISegmentedControl!
override func viewDidLoad() {
super.viewDidLoad()
for i in 0..<18 {
myData.append(MyObject(myID: i+1))
}
// layout properties
layout.itemSize = .init(width: 50.0, height: 50.0)
layout.itemSpacing = 8.0
layout.rowSpacing = 8.0
// create collection view
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
// controls
segCtrl = UISegmentedControl(items: ["Basic Anim", "Pre-Stack Anim"])
// button to toggle stacked/un-stacked
let btn = UIButton()
btn.setTitle("Stack / Un-Stack", for: [])
btn.setTitleColor(.white, for: .normal)
btn.setTitleColor(.lightGray, for: .highlighted)
btn.backgroundColor = .systemRed
btn.layer.cornerRadius = 8.0
[segCtrl, btn, collectionView].forEach { v in
v.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(v)
}
let g = view.safeAreaLayoutGuide
// let's make things easy on ourselves by setting the collection view width
// to the width of 5 cells + 4 spaces/gaps
let cvWidth: CGFloat = layout.itemSize.width * 5.0 + layout.itemSpacing * 4.0
NSLayoutConstraint.activate([
segCtrl.topAnchor.constraint(equalTo: g.topAnchor, constant: 12.0),
segCtrl.widthAnchor.constraint(equalToConstant: cvWidth),
segCtrl.centerXAnchor.constraint(equalTo: g.centerXAnchor),
btn.topAnchor.constraint(equalTo: segCtrl.bottomAnchor, constant: 12.0),
btn.widthAnchor.constraint(equalToConstant: cvWidth),
btn.centerXAnchor.constraint(equalTo: g.centerXAnchor),
collectionView.topAnchor.constraint(equalTo: btn.bottomAnchor, constant: 12.0),
collectionView.widthAnchor.constraint(equalToConstant: cvWidth),
collectionView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
collectionView.heightAnchor.constraint(equalToConstant: 320.0),
])
// collection view properties
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(SimpleCell.self, forCellWithReuseIdentifier: SimpleCell.reuseIdentifier)
collectionView.allowsMultipleSelection = true
// so we can see the framing
collectionView.backgroundColor = .init(red: 0.10, green: 0.45, blue: 0.95, alpha: 1.0)
// button action
btn.addTarget(self, action: #selector(btnTap(_:)), for: .touchUpInside)
segCtrl.selectedSegmentIndex = 0
}
@objc func btnTap(_ sender: Any?) {
// make sure we have some selected cells
guard let _ = collectionView.indexPathsForSelectedItems else { return }
layout.isCollapsed.toggle()
if layout.isCollapsed {
if segCtrl.selectedSegmentIndex == 0 {
basicAnim()
} else {
preStackAnim()
}
} else {
basicAnim()
}
}
func basicAnim(postStack: Bool = false) {
// we want a little quicker animation
// if we're "finishing" after pre-stacking
let dur: Double = postStack ? 0.25 : 0.5
UIView.animate(withDuration: dur, animations: {
self.layout.invalidateLayout()
self.collectionView.layoutIfNeeded()
}, completion: { b in
// if we want to do something on completion
})
}
func preStackAnim() {
struct Parent {
var parentCell: UICollectionViewCell = UICollectionViewCell()
var children: [UICollectionViewCell] = []
}
var parents: [Parent] = []
var curParent: Parent!
var inGroup: Bool = false
var maxChildren: Double = 0.0
for i in 0..<myData.count {
if let c = collectionView.cellForItem(at: .init(item: i, section: 0)) {
c.layer.zPosition = CGFloat(i)
let cNext = collectionView.cellForItem(at: .init(item: i+1, section: 0))
if c.isSelected {
if !inGroup {
if let cNext = cNext, cNext.isSelected {
curParent = Parent(parentCell: c, children: [])
inGroup = true
}
} else {
curParent.children.append(c)
// if we're at the last data item, close this parent
if i == myData.count - 1 {
parents.append(curParent)
maxChildren = max(maxChildren, Double(curParent.children.count))
}
}
} else {
if inGroup {
parents.append(curParent)
maxChildren = max(maxChildren, Double(curParent.children.count))
}
inGroup = false
}
}
}
// parents can be empty if we have no consecutive selected cells
// if this is the case, reset the layout isCollapsed to false and return
if parents.isEmpty {
layout.isCollapsed = false
return
}
var relStart: Double = 0.0
var relDur: Double = 0.0
var totalDur: Double = 1.0
var startInc: Double = 0.0
totalDur = min(0.75, 0.3 * maxChildren)
UIView.animateKeyframes(withDuration: totalDur,
delay: 0.0,
options: .calculationModeLinear, animations: {
parents.forEach { p in
relStart = 0.0
startInc = min(0.1, totalDur / Double(p.children.count))
relDur = 0.75
let f: CGRect = p.parentCell.frame
p.children.forEach { c in
UIView.addKeyframe(withRelativeStartTime: relStart, relativeDuration: relDur) {
c.frame = f
}
relStart += startInc
}
}
}, completion: { _ in
// animate the rest of the cells
self.basicAnim(postStack: true)
})
}
}
extension MyViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return myData.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SimpleCell.reuseIdentifier, for: indexPath) as! SimpleCell
cell.label.text = "\(myData[indexPath.item].myID)"
// we need to keep the collapsed/stacked cells layered in order
// so the "last" cell will be on top
// i.e. if we stack 4,5,6,7
// we don't want 5 to be on top of 7
cell.layer.zPosition = CGFloat(indexPath.item)
return cell
}
}
// MARK: - UICollectionViewDelegate
extension MyViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// we're currently not doing anything on cell selection
}
}
How it looks when running:
Notes:
Again, a lot of unknowns about your actual goals and implementations -- but this may give you some ideas.