swiftuicollectionviewuicollectionviewlayoutuicollectionviewcompositionallayoutuicollectionviewflowlayout

UICollectionView layout API for collapsing/stacking grid items


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


Solution

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

    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:

    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:


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