Craft At WillowTree Logo
Content for craftspeople. By the craftspeople at WillowTree.
Engineering

Dynamic Sizing for Horizontal UICollectionViews

One of the most common designs in iOS right now is the horizontal scrolling shelf. You can display a large amount of content without taking up an entire screen’s worth of real estate. This design is not without challenges to the engineer though. What do you do when your content is not reliable, or can vary dramatically in height? You can set a static height to an appropriate maximum, but that can leave you with a lot of wasted space and a frustrated designer.

In this post, we’ll go over a reliable way to size your horizontal scrolling UICollectionView to all have a uniform height that is only as large as it needs to be.

You can see a working example of the final product here.

To begin, we’ll set up a project that has a Storyboard with a collectionView, a nib for the collectionView cells, and some example data. For our sample project, our data has a title and body for the cells, but the body’s content can vary greatly, or even be totally absent! We’re going to calculate the height by:

Looping through our data to find the largest cell content. Rendering a sizing cell with said content. Calculating the height of the sizing cell. Adjusting the itemSize of the collectionView’s flowLayout, and height constraint on the collectionView itself, based on the height of the sizing cell.

This approach has several advantages. Since we are only looping through the data to to find the largest content, we are not unnecessarily rendering a lot of UICollectionViewCells. This also lets AutoLayout do most of the heavy lifting for us for the actual height calculation.

Let’s look at the individual steps we’re taking in more detail.

Finding the Largest Content

In the example project, we use the common ViewModel pattern for this task, as it allows for good separation of concerns for our code. Our viewModel here is fairly simple, as it just uses the properties of the model for display. It also conforms to the protocol DynamicHeightCalculable.

protocol DynamicHeightCalculable {
    func height(forWidth: CGFloat) -> CGFloat
}

Inside the height function defined by DynamicHeightCalculable, we determine the maximum height for whatever content can be dynamic for this viewModel. In the example’s case this is just a UILabel, but there can be many more. Note that we don’t specifically use the return value from this function, it is just used to determine which viewModel should be used to render our sizingCell. Consequently, you can use whatever method works in your case for determining content size. Here we are creating a sizingLabel and assigning its properties to be the same as the label in the content cell and then measuring its height. However, you could in theory even do something as simple as a word count and returning that.

struct ExampleViewModel: DynamicHeightCalculable {
    
    let title: String
    let body: String?
    
    init(example: ExampleModel) {
        title = example.title
        body = example.body
    }
    
    public func height(forWidth width: CGFloat) -> CGFloat {
        let sizingLabel = UILabel()
        sizingLabel.numberOfLines = 0
        sizingLabel.font = UIFont.systemFont(ofSize: 14.0, weight: .regular)
        sizingLabel.lineBreakMode = .byTruncatingTail
        sizingLabel.text = body
        
        let maxSize = CGSize(width: width, height: .greatestFiniteMagnitude)
        let size = sizingLabel.sizeThatFits(maxSize)
        
        return size.height
    }
    
}

We are actually looking through the viewModels in the calculateHeight function. This has been created generically to take any viewModel that conforms to the DynamicHeightCalculable protocol.

This should return you the content to populate your sizing cell with.

Rendering a Sizing Cell

There are two things our UICollectionViewCell needs in order to render a sizing cell:

A static property which is a concrete instance of the cell itself, to be used exclusively for sizing purposes. (There are lots of good helpers instantiating a nib, I recommend using one of them.)

private static let sizingCell = UINib(nibName: Constants.exampleCellReuseIdentifier, bundle: nil).instantiate(withOwner: nil, options: nil).first! as! ExampleCell

A static function that takes a viewModel and a width, and returns the actual height of the rendered cell.

public static func height(for viewModel: ExampleViewModel, forWidth width: CGFloat) -> CGFloat {
    sizingCell.prepareForReuse()
    sizingCell.configure(with: viewModel, isSizing: true)
    sizingCell.layoutIfNeeded()
    var fittingSize = UILayoutFittingCompressedSize
    fittingSize.width = width
    let size = sizingCell.contentView.systemLayoutSizeFitting(fittingSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow)
    
    guard size.height < Constants.maximumCardHeight else {
        return Constants.maximumCardHeight
    }
    
    return size.height
}

The first thing we do in the function is call the sizingCell’s prepareForReuse function to reset it to a clean state. We then configure the cell using it’s regular configure function, just like we would in the cellForReuseIdenifier function of the UICollectionViewDataSource. We override the default value for the isSizing parameter when we configure it via this function to avoid doing any unnecessary operations that will not affect the height. After configuring it, we call layoutIfNeeded on the cell to setup the AutoLayout. The final step is to actually get the size.

We use UILayoutFittingCompressedSize to specify that we want to use the smallest possible size, and set its width to be the static width of our cells. We then get the size from the system by calling systemLayoutSizeFitting on the contentView of the cell. We set the horizontal fitting priority to required because we have a static width, and vertical to low because we want it to expand as far as the AutoLayout will let it go. For our purposes, we have a maximum height that we don’t want to exceed, so we test to make sure that our height isn’t bigger than that before passing it back.

Using the Height

Now that we actually have the height we need to set two things with it:

The itemSize for the UICollectionView’s flowLayout. The height constraint on the UICollectionView itself.

The itemSize property of the flowLayout is the actual size that you want the cell to be. For ours, we set the width to be our static width that we have been using all along, and the height as our calculated height.

flowLayout.itemSize = CGSize(width: Constants.cardWidth, height: height)

You probably will need a vertical constraint on the collectionView when setting up your storyboard, so this will need to be adjusted, otherwise you could end up with unintended multiple rows if your content is small enough!

In addition, we need to take into consideration any vertical content insets that the collectionView may have, as we are setting the full height of the collectionView here.

collectionViewHeightConstraint.constant = height + edgeInsets.top + edgeInsets.bottom

Lastly, if we’re changing the height after the view loads (say we’re waiting for a network request to complete), we need to let the collectionView know that the layout and data have changed. Here we are accomplishing this via:

flowLayout.invalidateLayout()
collectionView.reloadData()

And viola, we have a scrolling shelf that is only as tall as it needs to be.

If at this point, you’re thinking to yourself, “That seems like a lot of work for not a lot of gain.”, just remember that it’s those small details that separate good apps from great apps. And it’s our job to make those small details possible.

Resources

Project demo: https://github.com/cschandler/DynamicHorizontalScrollingShelves

Charlie Chandler

Recent Articles