UICollectionView layout API for collapsing/stacking grid items

80 views Asked by At

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.

enter image description here

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

1

There are 1 answers

2
DonMag On BEST ANSWER

There are a lot of unknown parts to this, including ...

  • how the data is managed
  • your images appear to be multiple sections?
    • "15" is missing, but if 13, 14, 16, 17 are all selected, should 16 and 17 move from section 2 to section 1?
  • one of your comments says "30k+ rows"?
    • might you have 1,000 cells selected across 200 rows?
  • desired animation effect(s)
    • what happens if the "start" or "end" of the consecutive selected cells has been scrolled out of view?

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:

enter image description here

Now, suppose we select some cells:

enter image description here

If we want to "collapse" (or "stack") the consecutive selected cells, we can modify our logic slightly by saying:

  • if the current cell is selected, AND
  • the next cell is selected
    • don't change the current cell frame

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:

enter image description here

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:

enter image description here


Notes:

  • this is Example Code Only!!!
    • very little error-checking has been implemented
    • I did very little testing for "edge conditions"
  • the "Pre-Stack" animation will fail (perhaps spectacularly) if some of the selected cells have been scrolled out of view
  • the "Pre-Stack" animation logic was quickly cobbled together, so it "sorta" works.

Again, a lot of unknowns about your actual goals and implementations -- but this may give you some ideas.