Andrei Herford
Andrei Herford

Reputation: 18729

Understanding UICollectionViewFlowLayout sizing and spacing

I am working with a UICollectionView using UICollectionViewFlowLayout and have some difficulties to understand item sizing an spacing. I know that there several methods to adapt sizing and spacing (using the delegate methods, overriding FlowLayout, etc.). However without understanding the logic behind these values in the first place, it is quite hard to adapt them properly.

The following results have been created a default UICollectionViewController with a default UICollectionViewCell without any subclasses. Only the following settings haven been made:

enter image description here

Code:

private let reuseIdentifier = "Cell"

class MyViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        var layout: UICollectionViewFlowLayout {
            let layout = UICollectionViewFlowLayout()
            layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
            layout.minimumLineSpacing = 0
            layout.minimumInteritemSpacing = 5
            layout.sectionInset = UIEdgeInsets(top: 20, left: 20, bottom: 0, right: 20)
            layout.sectionInsetReference = .fromContentInset
            return layout
        }
        
        collectionView.collectionViewLayout = layout
    }

    override func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 2
    }


    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 3
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath)
        return cell
    }

    /*func collectionView(_ collectionView : UICollectionView,layout collectionViewLayout:UICollectionViewLayout,sizeForItemAt indexPath:IndexPath) -> CGSize {
        var width = view.frame.width
        return CGSize(width: collectionVw.frame.size.height, height: collectionVw.frame.size.height)
    }*/
}

Using different values for layout.minimumInteritemSpacing = 5 creates results I do not understand:

enter image description here

So: How exactly are sizes and spacing computed here?

Upvotes: 0

Views: 901

Answers (2)

DonMag
DonMag

Reputation: 77477

As you've already learned from Larme, your Storyboard layout is ignored because you create a new layout.

When the collection view lays out its cells, it says:

  • is there an .itemSize?
  • no, ask the cell for its size
  • cell returns size of .zero (your cell has no constraints affecting its size)
  • so, use the .estimatedItemSize

In your case, you set layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize which means "use the default of (50, 50)".

"So: How exactly are sizes and spacing computed here?"

Well, let's take a look at some more cells.

We'll use 6 sections...

  • 0 & 1 will have .minimumInteritemSpacing = 5.0
  • 2 & 3 will have .minimumInteritemSpacing = 10.0
  • 4 & 5 will have .minimumInteritemSpacing = 30.0

and we'll alternate between 3 and 7 items per section. Looks like this:

enter image description here

The answer is now pretty obvious - the collection view calculates how many total cells will fit (not how many cells are in the section) - based on cell-width plus minSpacing - and then increases the spacing for a "perfect fit."

So, assuming a collection view frame width of 320.0 and section insets of 20.0 on each side, we can calculate the adjusted item spacing:

min item spacing = 5.0

// CellWidth + MinSpacing
50.0 + 5.0 = 55.0

// collViewWidth - sectionInsets
320.0 - (20.0 + 20.0) = 280.0

// spacing is only BETWEEN cells, not at end, so
//  add spacing
280.0 + 5.0 = 285.0

// how many cells will fit?
285.0 / 55.0 = 5.1818181818181817

// can't have a "partial-cell" so 
floor(285.0 / 55.0) = 5.0
numCells = 5

// width of 5 cells
50.0 * numCells = 250.0

// "extra" space
280.0 - 250.0 = 30.0

// with 5 cells fitting, we have 4 "spaces" (numCells - 1)
30.0 / 4.0 = 7.5

actual spacing = 7.5

min item spacing = 10.0

// CellWidth + MinSpacing
50.0 + 10.0 = 60.0

// collViewWidth - sectionInsets
320.0 - (20.0 + 20.0) = 280.0

// spacing is only BETWEEN cells, not at end, so
//  add spacing
280.0 + 10.0 = 290.0

// how many cells will fit?
290.0 / 60.0 = 4.833333333333333

// can't have a "partial-cell" so 
floor(290.0 / 60.0) = 4.0
numCells = 4

// width of 4 cells
50.0 * 4.0 = 200.0

// "extra" space
280.0 - 200.0 = 80.0

// with 4 cells fitting, we have 3 "spaces"
80.0 / 3.0 = 26.666666 (rounded to 26.5 on @2x device scale)

actual spacing = 26.5

min item spacing = 30.0

// CellWidth + MinSpacing
50.0 + 30.0 = 80.0

// collViewWidth - sectionInsets
320.0 - (20.0 + 20.0) = 280.0

// spacing is only BETWEEN cells, not at end, so
//  add spacing
280.0 + 30.0 = 310.0

// how many cells will fit?
310.0 / 80.0 = 3.875

// can't have a "partial-cell" so 
floor(250.0 / 80.0) = 3.0
numCells = 3

// width of 3 cells
50.0 * 3.0 = 150.0

// "extra" space
280.0 - 150.0 = 130.0

// with 3 cells fitting, we have 2 "spaces"
130.0 / 2.0 = 65.0

actual spacing = 65.0

Here is some example code to see that result...

Simple cell with centered label:

class CenterLabelCell: UICollectionViewCell {
    
    let label = UILabel()
    override init(frame: CGRect) {
        super.init(frame: frame)
        commonInit()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        commonInit()
    }
    func commonInit() {
        label.backgroundColor = .yellow
        label.font = .systemFont(ofSize: 16.0, weight: .light)
        label.textAlignment = .center
        label.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(label)
        let g = contentView.layoutMarginsGuide
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: g.centerYAnchor),
        ])
    }
}

Example view controller:

class CollViewSpacingVC: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
    
    var collectionView: UICollectionView!
    
    let itemSpaces: [CGFloat] = [
        5.0, 10.0, 30.0,
    ]
    
    let sectionColors: [UIColor] = [
        .systemRed,   .systemRed.withAlphaComponent(0.5),
        .systemGreen, .systemGreen.withAlphaComponent(0.5),
        .systemBlue,  .systemBlue.withAlphaComponent(0.5),
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        var layout: UICollectionViewFlowLayout {
            let layout = UICollectionViewFlowLayout()
            layout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
            layout.minimumLineSpacing = 4
            layout.scrollDirection = .vertical
            layout.sectionInset = .init(top: 20.0, left: 20.0, bottom: 0.0, right: 20.0)
            return layout
        }
    
        collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(collectionView)
        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([
            collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
            collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
            collectionView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
            collectionView.widthAnchor.constraint(equalToConstant: 320.0),
        ])
        
        collectionView.register(CenterLabelCell.self, forCellWithReuseIdentifier: "c")
        collectionView.dataSource = self
        collectionView.delegate = self

        collectionView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)

    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return itemSpaces[section / 2]
    }
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return sectionColors.count
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return section % 2 == 0 ? 3 : 7
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "c", for: indexPath) as! CenterLabelCell
        cell.label.text = "\(Int(itemSpaces[indexPath.section / 2]))"
        cell.contentView.backgroundColor = sectionColors[indexPath.section]
        return cell
    }
    
}

Upvotes: 1

Larme
Larme

Reputation: 26036

You have multiple questions (not really recommended), I'll answer what I can do.

Why are the items 50 x 50px in size? I know that one can use ...sizeForItemAt to specify explicit dimensions. However, setting the size in IB should also work, shouldn't it? Why is the IB size of 200 x 200px ignored? Why 50x50, is this the default size or where is this specified?

You are doing:

collectionView.collectionViewLayout = layout

Where layout is newly created.
You aren't using the previous settings in InterfaceBuilder, you are overriding them by code.
And your created for layout, its itemSize hasn't been set. And from the doc, if not set, it's 50x50.

Why are the items aligned to the left for these values and distribued evenly of the complete width for value of 30?

Are you sure about that? Default Layout (meaning, not a inherited from UICollectionViewFlowLayout) will behave like paragraphs styling. I'll take the horizontal layout (same logic can be applied in vertical, but analogy with text paragraphs would be strange):

If you have multilines text, the first line would take as much width as possible, but the last line will not, keeping it "left" aligned.

For your spacing calculations, according to the doc of minimumInterItemSpacing:

For a vertically scrolling grid, this value represents the minimum spacing between items in the same row. For a horizontally scrolling grid, this value represents the minimum spacing between items in the same column. This spacing is used to compute how many items can fit in a single line, but after the number of items is determined, the actual spacing may possibly be adjusted upward.

But, I'm wondering, what would happen if you override viewDidLayoutSubview(), and call collectionView.collectionViewLayout?.invalidateLayout(); collectionView.collectionViewLayout?.prepareLayout().

Upvotes: 1

Related Questions