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
}
}
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:
your code is calculating a new origin.x
for each cell in sequence, resulting in this:
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:
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:
and rotated to landscape for emphasis (the top row actually goes all the way to 24):
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.