In this post, I’ll share sample code for implementing MasonryLayout using UICollectionViewCompositionalLayout.

UICollectionViewCell

I defined a very simple UICollectionViewCell. It consists of basic UI components such as Photo, Title and Description.

NSCollectionLayoutGroupCustomItem ( >= iOS 13)

Apple Documents

This is important to implement masonryLayout. Unfortunately It doesn’t support NSCollectionLayoutSize. (It supports GCRect only)

It means we need to calculate the cell’s contents size.

So We need to write 2 main logics.

Step 1. Calculate Cell’s size

To calculate size, We need to know cell’s width first.

We knows collectionView’s width by accessing UICollectionViewCompositionalLayout’s layoutEnvironment.container.contentSize.

private func masonryLayout(contentInset: NSDirectionalEdgeInsets) -> UICollectionViewCompositionalLayout {
    let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
    var items = [NSCollectionLayoutGroupCustomItem]()
    let snapshotItems = self?.dataSource.snapshot(for: .item).items
    let numberOfItems = snapshotItems?.count ?? 0
    let contentSize = layoutEnvironment.container.contentSize
            ...
}

Cell’s width

We have 2 columns. so Cell’s width is contentSize.width / columns.

If we set interItemSpacing and NSDirectionalEdgeInsets then logic is below

let horizontalSpacing = contentInsets.leading + contentInsets.trailing
        let spacing = (columns - 1) * interItemSpacing + horizontalSpacing
        return (contentSize.width - spacing) / columns

Cell’s Height

Calculate UIImageView’s size

In this example, I set image ratio as 4:3. So Image Height is cell’s width * 0.75.

Calculate UILabel’s size

To get the correct size, We need to provide the details such as Font, LineBreakMode and NumberOfLines.

Also to get the correct bounding size we set NSString.DrawingOptions.

If you want to know more details about drawing options visit Apple documents.

extension UILabel {
    var textHeight: CGFloat? {
    guard let labelText = text else {
        return nil
    }
    let attributes: [NSAttributedString.Key: UIFont] = [
            .font: font
]
    let labelTextSize = (labelText as NSString).boundingRect(
            with: CGSize(width: frame.size.width, height: .greatestFiniteMagnitude),
            options: .usesLineFragmentOrigin,
            attributes: attributes,
            context: nil
        ).size
        return ceil(labelTextSize.height)
    }
}

func height(text:String, font:UIFont, numberOfLines: CGFloat, lineBreakMode: NSLineBreakMode, width:CGFloat) -> CGFloat{
    let label:UILabel = UILabel(frame: CGRect(x: 0, y: 0, width: width, height: UILabel.noIntrinsicMetric))
    label.numberOfLines = numberOfLines
    label.lineBreakMode = lineBreakMode
    label.font = font
    label.text = text
    return label.textHeight ?? 0
}

Step 2. Calculate Cell’s Position in a Group

We need to layout cell’s position in a Group. CGRect has origin x, y and width, height. At step 1, We already calculated the cell’s width and height. In step 2, We need to calculate origins.

Calculate cell’s origin X and Y

Let’s take a look how to layout cells. (row is indicating item’s index)

First Item (row: 0, column: 0)

It’s origin is 0, 0

Second Item (row: 1, column: 1)

It’s origin y is 0. But x is first item’s width + interItemSpacing.

let originX = (cellWidth + interItemSpacing) * columnIndex

Third Item (row: 2, column: 0)

Its origin x is zero because columnIndex is zero. But origin y is calculated by maxY between First Item and Second Item. Second Item’s maxY is smaller than First Item. So We picked this to layout Third Item.

UICollectionViewCompositionalLayout for MasonryLayout (Pinterest Style)

Here is full source code.

cachedSize used for saving cell’s height. It will be called when you call a reloadData or applySnapshotUsingReloadData. (In this sample, I used dictionary but you can considering NSCache)

private func masonryLayout(contentInset: NSDirectionalEdgeInsets) -> UICollectionViewCompositionalLayout {
    let layout = UICollectionViewCompositionalLayout { [weak self] sectionIndex, layoutEnvironment in
    var items = [NSCollectionLayoutGroupCustomItem]()
    let snapshotItems = self?.dataSource.snapshot(for: .item).items
    let numberOfItems = snapshotItems?.count ?? 0
    let contentSize = layoutEnvironment.container.contentSize
    let itemProvider = MasonryLayoutProvider(
                columns: 2,
                interItemSpacing: 16,
                collectionWidth: contentSize.width,
                contentInsets: contentInset) { [weak self] row, cellWidth in
    //Use Cached Height
    if let cachedHeight = self?.cachedSize[row]?.height         {
        return cachedHeight
    }
    let height = snapshotItems?[row].estimatedHeight(width: cellWidth) ?? 0
    self?.cachedSize[row] = .init(width: cellWidth, height: height)
        return height
    }
    for i in 0..<numberOfItems {
        let item = itemProvider.makeLayoutItem(for: i)
        items.append(item)
    }
    let groupLayoutSize = NSCollectionLayoutSize(
                widthDimension: .fractionalWidth(1),
                heightDimension: .absolute(itemProvider.maxColumnHeight())
    )
            
    let group = NSCollectionLayoutGroup.custom(layoutSize: groupLayoutSize) { env in
        items
    }
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = contentInset
    section.boundarySupplementaryItems = [
                .init(layoutSize: .init(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(10)), elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
    ]
        return section
    }
    return layout
}

For more details, You can checkout my repository

References

https://www.kodeco.com/4829472-uicollectionview-custom-layout-tutorial-pinterest

https://github.com/eeshishko/WaterfallTrueCompositionalLayout

2 responses to “How to implement MasonryLayout (Pinterest Style) using UICollectionViewCompositionalLayout”

  1. rkrupeshkumar Avatar
    rkrupeshkumar

    Nice Shawn! any insights on how it will turn out if there’s odd number of cards, and the last card fitting to the right because the previous card height is shorter on right..

    Liked by 1 person

  2. Traveller Avatar

    Thanks for your comment. You want to fit left and right at the last?

    It is bit tricky I suggest adding a empty cell that inform user It is the last cell. That cell’s height will filling the gap.

    Like

Leave a comment

Quote of the week

"People ask me what I do in the winter when there's no baseball. I'll tell you what I do. I stare out the window and wait for spring."

~ Rogers Hornsby