Search code examples
iosswiftuicollectionviewuicollectionviewlayout

Calculating Size of Cell for CollectionView Mosaic Layout


I'm trying to make a mosaic collection view layout similar to Google's Keep app. I've subclassed UICollectionViewLayout similar to the many tutorials found online. In order to properly layout the collection view cells, the calling class must implement a delegate, HeightForCellAtIndexPath method to get the cell's height. In my case, I also get the cell's width to create 1, 2 or 3 column layouts.

In all of the tutorials, the height of the cell's content is known and does not need to be computed. In my case, the size of content is not known and needs to be computed. I've tried many different ways of calculating this but none work perfectly. My latest attempt entails creating a CardContent class and adding that to a cell's contentView in cellForItemAt and also instantiate a CardContent instance in HeightForCellAtIndexPath to calculate the size of the content that is passed to the layout class.

I'm sure there are many problems with my methodology, but from what I can gather, the issue appears to be with the multi-line labels not laid out correctly in HeightForCellAtIndexPath in that the labels are not wrapping to multi line and remain as a single line thus giving me an incorrect height of the contentView.

CardContentCell.swift

import UIKit

class CardContentCell: UICollectionViewCell {
    var todoList: TodoList! {
        didSet {
            self.backgroundColor = UIColor(todoList.color)
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.layer.cornerRadius = 5.0
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

CardContent.swift

Edit: Added createLineItem method. See answer below.

class CardContent: UIStackView {

    var todoList: TodoList!

    var verticalItemSpacing: CGFloat = 10.0

    var cellWidth: CGFloat!


    init(todoList: TodoList, cellWidth: CGFloat = 0.0) {
        self.todoList = todoList
        self.cellWidth = cellWidth

        super.init(frame: CGRect(x: 0, y: 0, width: cellWidth, height: 0))

        self.axis = .vertical
        self.alignment = .fill
        self.distribution = .fill
        self.contentMode = .scaleToFill

        self.spacing = 10.0

        layoutContent()
    }

    required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func createTitleLabel(title: String) -> UILabel {
        let label = UILabel()
        label.text = title
        label.font = label.font.withSize(20.0)
        label.numberOfLines = 2
        label.lineBreakMode = .byTruncatingTail
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }

    func createItemLabel(text: String) -> UILabel {
        let label = UILabel()
        label.text = text
        label.font = label.font.withSize(17.0)
        label.numberOfLines = 3
        label.lineBreakMode = .byTruncatingTail
        label.translatesAutoresizingMaskIntoConstraints = false
        label.sizeToFit()
        return label
    }

    func createLineItem(text: String) -> UIStackView {
        let hstack = UIStackView()
        hstack.axis = .horizontal
        hstack.alignment = .fill
        hstack.distribution = .fillProportionally

        let imgView = createImgView(withFont: lineItemFont)
        let textLabel = createItemLabel(text: text)

        hstack.addArrangedSubview(imgView)
        hstack.addArrangedSubview(textLabel)

        return hstack
    }

    func layoutContent() {

        self.addArrangedSubview(createTitleLabel(title: todoList.title))

        for todo in todoList.todos.prefix(6) {
            let lineItem = createLineItem(text: todo.text)
            self.addArrangedSubview(lineItem)

        }
    }

}

MyCollectionView.swift

extension MyCollectionView: UICollectionViewDataSource {

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellID, for: indexPath) as! CardContentCell
        cell.todoList = todoLists[indexPath.row]
        let content = CardContent(todoList: cell.todoList)
        cell.contentView.addSubview(content)
        content.pinTopAndSides(to: cell.contentView) // See extension below

        return cell
    }
}


extension MyCollectionView: CardLayoutDelegate {
    func collectionView(_ collectionView: UICollectionView, HeightForCellAtIndexPath indexPath: IndexPath, cellWidth: CGFloat) -> CGFloat {

        let todoList = todoLists[indexPath.row]
        let stackView = CardContent(todoList: todoList, cellWidth: cellWidth)

        stackView.translatesAutoresizingMaskIntoConstraints = false

        stackView.setNeedsLayout()
        stackView.layoutIfNeeded()

        let size = stackView.frame.size

        return size.height
    }
}

extension UIView {

    func pinTopAndSides(to other: UIView) {
        translatesAutoresizingMaskIntoConstraints = false
        leadingAnchor.constraint(equalTo: other.leadingAnchor).isActive = true
        trailingAnchor.constraint(equalTo: other.trailingAnchor).isActive = true
        topAnchor.constraint(equalTo: other.topAnchor).isActive = true
    }
}

The result is, if there are always 6 line items, then the computed height is always 230 (in a 2 column layout). In the screen shot below, the cell is colored while the rest of the content overflows.

screen shot of collection view


Solution

  • Barring a better solution, the answer for me involved not using a nested horizontal UIStackview. That was fraught with unknowns and hard to diagnose auto layout issues. Instead, I used a UIView and added my own constraints.

    Here's the method that creates said view. It's interesting that no one took a close enough look at my question that in my hurry to copy and past, I omitted this most crucial method in the original post. I will update the question with the original implementation of this method for reference.

    func createLineItem(text: String) -> UIView {
        let view = UIView()
    
        let imgView = createImgView(withFont: lineItemFont)
        imgView.translatesAutoresizingMaskIntoConstraints = false
    
        let textLabel = createItemLabel(text: text)
        textLabel.translatesAutoresizingMaskIntoConstraints = false
    
        imgView.tintColor = self.textColor
    
        view.addSubview(imgView)
        view.addSubview(textLabel)
    
        imgView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
        imgView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
    
        textLabel.leadingAnchor.constraint(equalTo: imgView.trailingAnchor, constant: 5.0).isActive = true
        textLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
        textLabel.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
        textLabel.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
    
        return view
    }
    

    And,as for the HeightForCellAtIndexPath delegate function, setting the widthAnchor to the cell width provided the correct height of the cell:

    func collectionView(_ collectionView: UICollectionView, HeightForCellAtIndexPath indexPath: IndexPath, cellWidth: CGFloat) -> CGFloat {
        let stackView = CardContent(todoList: todoList)
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.widthAnchor.constraint(equalToConstant: cellWidth).isActive = true
        stackView.setNeedsLayout()
        stackView.layoutIfNeeded()
        let size = stackView.frame.size
        return size.height
    }