(I tested this with iOS 9, 10, and 11)

In newer versions of iOS (maybe 9?), you can do away with custom height calculation of collection view cells, and rely entirely on flow layout’s estimatedItemSize function and autolayout to automatically find the correct height and width. However, sometimes a custom, manual, height calculation is required. Examples would be when you want to fit the width of a cell the entire width of the screen, or when you want to force a height to be 0, then you’ll need manual calculation.

multi-line-scroll

As a reference for myself and others, I documented this down so no one will need to fiddle with this again in the future. So assuming you have a collection view in place, and a custom cell subclass, let’s put in the pieces to make the above image work.

Reference Code

Within the ViewController class:

// 1.
class ViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout
  1. Whichever class you have that’s going to be doing the height calculation needs to conform to UICollectionViewDelegateFlowLayout
// 2.
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as! TestCell
        configure(cell: cell)
        return cell
    }
    
    // 3.
    func configure(cell: TestCell) {
        cell.titleLabel.text = "Ooh woo, I'm a rebel just for kicks, now. I been feeling it since 1966, now. Might've had your fill, but you feel it still"
        cell.subtitleLabel.text = "Feel It Still, by Portugal. The Man. Pretty good song I'm listening to while writing the configure method of this cell. "
    }
    
    // 4.
    let sizingCell = TestCell()
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        
        // 5.
        let width = collectionView.frame.size.width
        sizingCell.contentView.bounds.size.width = width

        // 6.
        configure(cell: sizingCell)
        
        // 7.
        sizingCell.contentView.setNeedsLayout()
        sizingCell.contentView.layoutIfNeeded()
        
        // 8.
        let height = sizingCell.contentView.systemLayoutSizeFitting(CGSize(width: width, height: UILayoutFittingCompressedSize.height)).height
        
        // 9.
        sizingCell.prepareForReuse()
        
        return CGSize(width: width, height: height)
    }

2. In your cellForItemAt method, dequeue a TestCell cell and configure it with a common configure method that the item height method can also later use.

3. Create a common cell configuration method, so that the cell set up and the cell height calculation methods use the same one.

4. Create an instance of the TestCell, to be used only for cell size calculations. Do not use this cell in the collection view dequeue.

5. This is the key part of the cell height calculation. All cell subviews are added to its contentView, so we’re essentially trying to answer the question – if the cell’s width is the entire screen width, what would its height be? So first make the cell’s bounds width the collection view width.

6. Call the common configure(_ cell:) method that was used within the cell dequeue. Normally you would also pass in some view model to configure with too, but let’s keep it simple here.

7. We’ve got the cell’s bounds set to the right width, its content is filled in (2 multiline text labels in this case), so now we want to ask the cell to lay itself out again, so that the word wrap is correct on the text labels.

8. Use systemLayoutSizeFitting to calculate the theoretical autolayout size and height of the cell. Note that it’s on the cell’s content view.

9. Call prepareForReuse() so that the sizing cell can clean up anything it needs to. This normally gets called automatically if the cell is dequeued, but we’re using a single instance here to do our height calculations, so we need to call this ourselves.

 

Within the TestCell class

class TestCell: UICollectionViewCell {
    
    let titleLabel: UILabel
    let subtitleLabel: UILabel
    
    override init(frame: CGRect) {
        // 10.
        titleLabel = UILabel()
        titleLabel.textAlignment = .left
        titleLabel.numberOfLines = 0
        titleLabel.lineBreakMode = .byWordWrapping
        titleLabel.backgroundColor = UIColor.green
        
        subtitleLabel = UILabel()
        subtitleLabel.textAlignment = .left
        subtitleLabel.numberOfLines = 0
        subtitleLabel.lineBreakMode = .byWordWrapping
        subtitleLabel.backgroundColor = UIColor.red

        super.init(frame: frame)
        
        contentView.backgroundColor = UIColor.white
        
        contentView.addSubview(titleLabel)
        contentView.addSubview(subtitleLabel)
        
        configureLabels()
    }
    
    func configureLabels() {
        // 11.
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
        
        // 12.
        let top = titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20)
        top.priority = UILayoutPriority(999)
        let leading = titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10)
        let trailing = titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10)
        NSLayoutConstraint.activate([top, leading, trailing])

        NSLayoutConstraint.activate([
            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
            subtitleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10),
            subtitleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
            subtitleLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20)
            ])
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // 13.
    override func layoutSubviews() {
        super.layoutSubviews()
        titleLabel.preferredMaxLayoutWidth = titleLabel.frame.size.width
        subtitleLabel.preferredMaxLayoutWidth = subtitleLabel.frame.size.width
        super.layoutSubviews()
    }
}

10. Basic cell setup. The big thing to note here is setting numberOfLines = 0and lineBreakMode = .byWordWrapping.

11. Setting up the label constraints. Some very important points here. First, if you use the layout anchors to setup constraints you must set translatesAutoresizingMaskIntoConstraints = false on all the subviews that you’re adding constraints to, but not on its superview. So here, we don’t set it on the content view, only the labels.

12. Setting up the actual constraints using layout anchors. That’s a whole other subject in itself. You can use whatever approach you like. I’ve been using layout anchors and like them. The only drawback is setting the priority is a pain. Here we set one of the vertical distance constraints to priority = 999 to avoid the console complaining about constraints breaking. When the cell first appears, its height is briefly 0, causing default constraints with priority 1000 to break. When you set it to 999, it still enforces a high priority, without the console shitting itself.

13. Another really important piece. Labels don’t know how wide you want to draw its text. You’d assume this would be default, but no. So the first super.layoutSubviews() call is to draw the labels according to autolayout so they’re the correct width. Then we set the preferredMaxLayoutWidth to the frame width so that the inner text is wraps with the width of the label. Then we call super.layoutSubviews() to get the overall autolayout correct with word wrapping involved.

 

0 replies

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply

Your email address will not be published. Required fields are marked *