Home > Software design >  How to make a horizontal UICollectionView have the same spacing between dynamic cells
How to make a horizontal UICollectionView have the same spacing between dynamic cells

Time:06-14

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

CodePudding user response:

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.

  • Related