iosswiftuicollectionviewuicollectionviewflowlayout

How to make a horizontal UICollectionView have the same spacing between dynamic cells


I have a dynamic collectionView, and essentially the spacing between cells needs to be the same regardless the width of the cell.

Found similar answers here and on the internet, but all were for vertical scrolling collectionViews. So, I went on and tried to work further on one of those answers to achieve what I want, with no much luck.

Currently, my collectionView has the same spacing between cells, but after each cell, it moves to the next row, although I'm not changing or manipulating the y offset of the attributes. Also, not all cells are visible.

Please, can you point out what I'm doing wrong? Thanks.

The subclass of UICollectionViewFlowLayout that I'm using is:

class TagsLayout: UICollectionViewFlowLayout {
    
    let cellSpacing: CGFloat = 20
        override init(){
            super.init()
            scrollDirection = .horizontal
        }

        required init(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)!
            self.scrollDirection = .horizontal
        }

        override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
            guard let attributes = super.layoutAttributesForElements(in: rect) else {
                return nil
            }
            
            guard let attributesToReturn =  attributes.map( { $0.copy() }) as? [UICollectionViewLayoutAttributes] else {
                return nil
            }
            var leftMargin = sectionInset.left
            var maxX: CGFloat = -1.0
            attributesToReturn.forEach { layoutAttribute in
                if layoutAttribute.frame.origin.x >= maxX {
                    leftMargin = sectionInset.left
                }

                layoutAttribute.frame.origin.x = leftMargin

                leftMargin += layoutAttribute.frame.width + cellSpacing
                maxX = max(layoutAttribute.frame.maxX , maxX)
            }

            return attributesToReturn
        }
}

enter image description here


Solution

  • As I said in my comment, you are using code for a "left-aligned vertical scrolling" collection view.

    A horizontal scrolling collection view lays out the cells like this:

    enter image description here

    your code is calculating a new origin.x for each cell in sequence, resulting in this:

    enter image description here

    You could modify your custom flow layout to keep track of a maxX for each "row" ... but, if you have a lot of cells as soon as you scroll so the first few "columns" are out-of-view, those cells will no longer be factored into the layout.

    So, you could attempt to "pre-calculated" the frame widths and x-origins of all your cells, and get close to your goal:

    enter image description here

    Two more issues though...

    First, assuming your cells contain longer strings than shown in these images, the collection view doesn't do a good job of figuring out which cells actually need to be shown. That is, the collection view will use the estimated items size to decide if a cell will need to be rendered. If the modification to the cells origin.x values would not fall within the expected range, certain cells will not be rendered because the collection view won't ask for them.

    Second, if you have varying-width tags, you could end up with something like this:

    enter image description here

    and rotated to landscape for emphasis (the top row actually goes all the way to 24):

    enter image description here

    You may want to re-think your approach and either go with a vertical-scrolling left-aligned collection view, or a horizontal-scrolling collection view with equal-width cells, or some other approach (such as a normal scroll view with subviews laid-out via your own code).

    I did create classes using the "pre-calculate" approach -- here they are if you want to give it a try.

    Simple cell with a label:

    class TagCell: UICollectionViewCell {
        let label = UILabel()
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            label.textAlignment = .center
            label.translatesAutoresizingMaskIntoConstraints = false
            contentView.addSubview(label)
            let g = contentView.layoutMarginsGuide
            NSLayoutConstraint.activate([
                label.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
                label.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
                label.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
                label.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
            ])
            
            // default (unselected) appearance
            contentView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
            label.textColor = .black
            
            // let's round the corners so it looks nice
            contentView.layer.cornerRadius = 12
        }
    }
    

    Modified custom flow layout:

    class TagsLayout: UICollectionViewFlowLayout {
        
        var cachedFrames: [[CGRect]] = []
        
        var numRows: Int = 3
        
        let cellSpacing: CGFloat = 20
    
        override init(){
            super.init()
            commonInit()
        }
        required init(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)!
            commonInit()
        }
        func commonInit() {
            scrollDirection = .horizontal
        }
        override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
    //      guard let attributes = super.layoutAttributesForElements(in: rect) else {
    //          return nil
    //      }
    
            // we want to force the collection view to ask for the attributes for ALL the cells
            //  instead of the cells in the rect
            var r: CGRect = rect
            // we could probably get and use the max-width from the cachedFrames array...
            //  but let's just set it to a very large value for now
            r.size.width = 50000
            guard let attributes = super.layoutAttributesForElements(in: r) else {
                return nil
            }
    
            guard let attributesToReturn =  attributes.map( { $0.copy() }) as? [UICollectionViewLayoutAttributes] else {
                return nil
            }
    
            attributesToReturn.forEach { layoutAttribute in
    
                let thisRow: Int = layoutAttribute.indexPath.item % numRows
                let thisCol: Int = layoutAttribute.indexPath.item / numRows
    
                layoutAttribute.frame.origin.x = cachedFrames[thisRow][thisCol].origin.x
            }
            
            return attributesToReturn
        }
    }
    

    Example controller class with generated tag strings:

    class HorizontalTagColViewVC: UIViewController {
        
        var collectionView: UICollectionView!
        
        var myData: [String] = []
        
        // number of cells that will fit vertically in the collection view
        let numRows: Int = 3
        
        override func viewDidLoad() {
            super.viewDidLoad()
    
            // let's generate some rows of "tags"
            //  we're using 3 rows for this example
            for i in 0...28 {
                switch i % numRows {
                case 0:
                    // top row will have long tag strings
                    myData.append("A long tag name \(i)")
                case 1:
                    // 2nd row will have short tag strings
                    myData.append("Tag \(i)")
                default:
                    // 3nd row will have numeric strings
                    myData.append("\(i)")
                }
            }
            
            // now we'll pre-calculate the tag-cell widths
            let szCell = TagCell()
            let fitSize = CGSize(width: 1000, height: 50)
            var calcedFrames: [[CGRect]] = Array(repeating: [], count: numRows)
            for i in 0..<myData.count {
                szCell.label.text = myData[i]
                let sz = szCell.systemLayoutSizeFitting(fitSize, withHorizontalFittingPriority: .defaultLow, verticalFittingPriority: .required)
                let r = CGRect(origin: .zero, size: sz)
                calcedFrames[i % numRows].append(r)
            }
            // loop through each "row" setting the origin.x to the
            //  previous cell's origin.x + width + 20
            for row in 0..<numRows {
                for col in 1..<calcedFrames[row].count {
                    var thisRect = calcedFrames[row][col]
                    let prevRect = calcedFrames[row][col - 1]
                    thisRect.origin.x += prevRect.maxX + 20.0
                    calcedFrames[row][col] = thisRect
                }
            }
    
            let fl = TagsLayout()
            // for horizontal flow, this is becomes the minimum-inter-line spacing
            fl.minimumInteritemSpacing = 20
            // we need this so the last cell does not get clipped
            fl.minimumLineSpacing = 20
            // a reasonalbe estimated size
            fl.estimatedItemSize = CGSize(width: 120, height: 50)
            
            // set the number of rows in our custom layout
            fl.numRows = numRows
            // set our calculated frames in our custom layout
            fl.cachedFrames = calcedFrames
            
            collectionView = UICollectionView(frame: .zero, collectionViewLayout: fl)
            
            // so we can see the collection view frame
            collectionView.backgroundColor = .cyan
            
            collectionView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(collectionView)
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
                collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
                collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
                collectionView.heightAnchor.constraint(equalToConstant: 180.0),
            ])
            
            collectionView.register(TagCell.self, forCellWithReuseIdentifier: "cell")
            collectionView.dataSource = self
            collectionView.delegate = self
            
        }
        
    }
    extension HorizontalTagColViewVC: UICollectionViewDataSource, UICollectionViewDelegate {
        func numberOfSections(in collectionView: UICollectionView) -> Int {
            return 1
        }
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return myData.count
        }
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let c = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! TagCell
            c.label.text = myData[indexPath.item]
            return c
        }
    }
    

    Note that this is Example Code Only!!! It has not been tested and may or may not fit your needs.