Swift tutorial: Collection view layout with flexible cell sizes

flexible cell sizes swift ios programming tutorial

There are many use cases where you want your app to display collections of UI elements with flexible cell sizes, that render compactly on the screen, taking up as much space as possible. Unfortunately, achieving this in Swift is not straightforward and having collection views dealing with cells of different heights and widths can be tricky. In this tutorial, you’ll be able to learn how you can create the layout pictured below:

iphone swift collection view layout flexible sizes

Feel free to jump straight to the Swift source code, if you feel like the code could be self-explanatory. We recommend to read through this tutorial anyway, to make sure you understand the basics of how collection view layouting works on iOS.

This layout tutorial assumes that you’re already familiar with UICollectionView and UICollectionViewController, the basic iOS UI components that allow us to render similar items (products, articles, news, etc) in a list view.

In order to achieve the fluid layout that displays items of different sizes, we’ll be using our own subclass of UICollectionViewLayout, which represents an object that gives instructions to the collection view about how it has to lay out its cell views. These instructions are being sent via the methods on UICollectionViewLayout, such as prepare(), layoutAttributesForElementsInRect:, collectionViewContentSize:, etc.

The central piece of this layout is “override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?“, on the UICollectionViewLayout subclass. We’ll override this method and our custom layout class will feed this array of attributes to the collection view.

An attributes object (UICollectionViewLayoutAttributes instance) represents a piece of information related to how an item at a specific indexPath should be laid out. It has properties such as indexPath, center, frame, alpha, etc. You get the idea.

The “layoutAttributesForElementsInRect:” method gets called by the collection view when it needs to lay out its subviews (rotation, scroll, model updates, etc). In response, it expects an array of layout attributes that are associated with all the items whose frames would intersect the rectangle provided in the input.

This method gets called often, so it shouldn’t be computationally expensive. To optimize it, we won’t compute the attributes for each index path every time this method gets called, but instead, calculate the layout frames only once, cache the result and then serve it to the collection view directly, when asked. This is where the “prepare()” method comes into play. This method is being called only once, right before the rendering starts (but after the whole context (collection view, screen, etc) is ready). At this point, we’ll compute and cache the attributes for all the index paths that exist in the collection view.

This is how the “prepare()” method implementation looks like:

override public func prepare() {
        guard let collectionView = collectionView else { return }

        let numberOfColumns = Int(contentWidth / cellWidth) // #3
        let totalSpaceWidth = contentWidth - CGFloat(numberOfColumns) * cellWidth
        let horizontalPadding = totalSpaceWidth / CGFloat(numberOfColumns + 1)
        let numberOfItems = collectionView.numberOfItems(inSection: 0)

        if (contentWidth != cachedWidth || self.numberOfItems != numberOfItems) { // #1
            cache = []
            contentHeight = 0
            self.numberOfItems = numberOfItems
        }

        if cache.isEmpty { // #2
            cachedWidth = contentWidth
            var xOffset = [CGFloat]()
            for column in 0 ..< numberOfColumns {
                xOffset.append(CGFloat(column) * cellWidth + CGFloat(column + 1) * horizontalPadding)
            }
            var column = 0
            var yOffset = [CGFloat](repeating: 0, count: numberOfColumns)

            for row in 0 ..< numberOfItems {

                let indexPath = IndexPath(row: row, section: 0)

                let cellHeight = delegate.collectionView(collectionView: collectionView, heightForCellAtIndexPath: indexPath, width: cellWidth)
                let height = cellPadding +  cellHeight + cellPadding
                let frame = CGRect(x: xOffset[column], y: yOffset[column], width: cellWidth, height: height)
                let insetFrame = frame.insetBy(dx: 0, dy: cellPadding)

                let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath as IndexPath) // #4
                attributes.frame = insetFrame // #5
                cache.append(attributes) // #6

                contentHeight = max(contentHeight, frame.maxY)
                yOffset[column] = yOffset[column] + height

                if column >= (numberOfColumns - 1) {
                    column = 0
                } else {
                    column = column + 1
                }
            }
        }
    }

First of all, we need to make sure that when this gets executed, we don’t have a valid cache already. If we do, then we just early return, because there’s nothing new to be done. The cache becomes invalid (#1) if the width of the screen changes (after rotation, for instance) or if the number of items in the collection view is different than the previous one (for which we computed the cache last time).

Now, if the cache is invalid (#2), we computed the number of columns we expect our collection view to have (#3). This is just simple math – basically, you know the width of the screen and you know the width of one cell, so you only divide these two numbers (you should factor in the padding between cells as well).

In order to populate the attributes cache, we iterate through all the items in the collection and compute the frame of their cell manually. The frame is a rectangle, so you need to calculate the x and y coordinate, as well as its width and height. The math is pretty simple, so we won’t go into many details. If you take a look at #4, #5, and #6, you can see how we instantiate a UICollectionViewLayoutAttributes object, for the index path that’s currently being processed, we set the attributes object’s frame to the manually computed value, and then append it to our cache.

Once the loop is over, we’ll have in our cache, a list of the correct attributes for all of our items in the list.

The hard part is over, and the implementation of “layoutAttributesForElementsInRect:” becomes very trivial:

override public func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var layoutAttributes = [UICollectionViewLayoutAttributes]()

        for attributes in cache { // #7
            if attributes.frame.intersects(rect) { // #8
                layoutAttributes.append(attributes) // #9
            }
        }
        return layoutAttributes
    }

We iterate through all the layout attributes in the cache (#7), and we append to the result (#9) the ones that intersect the input rectangle (#8). Using the result of this method, the collection view will know how to render its own cells.

Note: The code uses “LiquidLayoutDelegate“, which contains a method for computing the height, given the index path. This delegate is usually the view controller, that has access and knowledge about all the current items in the collection.

That’s all you need in order to layout a collection view with flexible cell sizes, in Swift. While the code might look scary at first sight, things are actually pretty simple. Hopefully, Apple will introduce a nicer way of achieving this dynamic layout, without all this boilerplate code. Until then, feel free to use the classes we provided in this Swift tutorial on collection view layouts.

Here’s the entire Swift source code you’ll need to render flexible cell sizes on iOS.

Leave a Reply

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