đź“ť Reusing programmatic cells in UICollectionViews

Over the past few months I’ve been trying to dive into topics in iOS that I don’t mess with on a day to day basis. One of those things has been working with UICollectionViews. Using custom, programmatic UICollectionViewCells is something I’ve wanted to learn how to do but surprisingly I couldn’t find much on the internet that didn’t involve working with xibs or Storyboards, so I experimented a bit and have written up findings along with an example, which can be found here.

The basics of UICollectionViewCell

At its most basic function, a UICollectionViewCell is a type of UIView that stores content to display within a UICollectionView.

You create or reuse cells when calling dequeueReusableCell(withReuseIdentifier:for:) and returning it within UICollectionViewDataSource’s collectionView(_:cellForItemAt:):

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "reusableCell", for: indexPath)
    return cell
}

Created or reused? What does that mean? Can’t I just init a new UICollectionViewCell?

This is what I first thought - but Apple specifically warns against this in their documentation for UICollectionViewCell:

You typically do not create instances of this class yourself. Instead, you register your specific cell subclass (or a nib file containing a configured instance of your class) with the collection view object. When you want a new instance of your cell class, call the dequeueReusableCell(withReuseIdentifier:for:) method of the collection view object to retrieve one.

Okay, so we’re supposed to use this dequeueReusableCell(withReuseIdentifier:for:) method to create or reuse cells. Why do we need to do this?

Lets say you have 500 cells you want to display in your collection view. Obviously these can’t fit on the device’s screen, so it doesn’t make sense to load the content for all of the cells at once as that would be performance intensive and unecessary. Luckily, Apple takes care of this for us and only loads the cell when we need it. dequeueReusableCell(withReuseIdentifier:for:) will try to reuse an existing cell, and if it can’t find one, it will initialize a new one.


1 2 3 4 5 6 7 8 9


As the user scrolls along the collection view, cells are dequeued for reuse later on when they re-appear on the screen.

Setting up custom UICollectionViewCells

As a result of using dequeueReusableCell(withReuseIdentifier:for:), we can’t use a regular init method on UICollectionViewCell (or any subclasses of UICollectionViewCell, which is what we eventually want to do).

The attempted approach I’ve seen in in examples and across The Internet is to write up your custom subclass and then when its time to call dequeueReusableCell(withReuseIdentifier:for:), force downcast your cell to your custom subclass, like this:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "reusableCell", for: indexPath) as! CustomCollectionViewCell
    return cell
}

Don’t forget, you’ll also need to register your subclass with your collection view too:

collectionView.register(CustomCollectionViewCell.self, forCellWithReuseIdentifier: "reusableCell")

Customizing cell content

For purposes of learning I wanted to create my own, custom UICollectionViewCell programmatically. In this case, I decided my goal was to create a cell that has a number label inside of it, horizontally and vertically centered within its enclosing view.

The first approach I took was to create a new UICollectionViewCell subclass, called CustomCollectionViewCell, that looked like this:

class CustomCollectionViewCell: UICollectionViewCell {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.contentView.layer.backgroundColor = UIColor.purple.withAlphaComponent(0.3).cgColor
        self.contentView.layer.cornerRadius = 8.0
    }

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

    func configureLabel(with number: Int) {
        let label = UILabel(frame: CGRect.zero)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = UIFont.systemFont(ofSize: 24.0, weight: .bold)
        label.text = "\(number)"

        self.contentView.addSubview(label)

        NSLayoutConstraint.activate([
            label.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            label.centerXAnchor.constraint(equalTo: contentView.centerXAnchor)
        ])
    }
}

And then when its time to queue up a cell, I would call configureLabel on my cell to add the label and set up its layout:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "reusableCell", for: indexPath) as! CustomCollectionViewCell
    // `values` are an array of Ints from 1 to 30
    let numberValue = values[indexPath.row]
    cell.configureLabel(with: numberValue)

    return cell
}

Makes sense right? When we create the cell, lets setup its label. However, the application says otherwise:

broken-scroll

Yikes. While it starts off looking ok, when I scroll on the collection view, views start overlapping each other. Why is that?

Our friendly method, dequeueReusableCell(withReuseIdentifier:for:) is returning a new cell or trying to reuse an old one. It will never return nil, so we can’t check for that. Unfortunately, Apple doesn’t provide a way to determine whether or not a cell is new or reused - it just always guarantees a cell is returning.

Reading up a bit more around Apple’s documentation, I see a sliver of hope buried within the documentation for UITableViewCell’s prepareForReuse method:

The table view’s delegate in tableView(_:cellForRowAt:) should always reset all content when reusing a cell.

While this was specifically in the documentation for UITableViewCell, it makes sense for UICollectionViewCell as well. Since dequeueReusableCell(withReuseIdentifier:for:) can return a previously created cell, its contents should be reset before being displayed again.

Customizing cell content…without the layout mess

I went off on a tangent here and also read up on UICollectionView’s prepareForReuse method, because it made sense in my mind that cleaning up existing cells should happen here. But, Apple warns again in the same documentation:

For performance reasons, you should only reset attributes of the cell that are not related to content, for example, alpha, editing, and selection state.

So, the resetting of the cell’s content before reuse needs to happen elsewhere. This is where I ended up struggling to find examples, and after some careful prodding, searching, and experimentation I came up with the approach below:

1) Create a new numberValue property to store the number data. This will have an attached didSet property observer that will update the cell’s subviews with a when the property is set.

2) Create a new method called updateSubviews within the CustomCollectionViewCell. This method will check if the UICollectionViewCell already has its custom subviews set, removing them if they already exist, and setting them up again if they don’t. This is the mechanism used to clean up the cell’s contents before reusing. This method will be called when didSet is called from numberValue.

3) Set the numberValue property within collectionView(_:cellForItemAt:) to trigger all of the above.

Below is the implementation of CustomCollectionViewCell:

class CustomCollectionViewCell: UICollectionViewCell {

    var numberView: UILabel!

    var numberValue: Int? {
        didSet {
            updateSubviews()
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.contentView.backgroundColor = UIColor.purple.withAlphaComponent(0.3)
    }

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

    private func updateSubviews() {
        if numberView != nil {
            self.numberView.removeFromSuperview()
        }

        configureLabel()
    }

    private func configureLabel() {
        guard let numberValue = self.numberValue else { return }

        numberView = UILabel(frame: CGRect.zero)
        numberView.translatesAutoresizingMaskIntoConstraints = false
        numberView.font = UIFont.systemFont(ofSize: 24.0, weight: .bold)
        numberView.text = "\(numberValue)"

        self.contentView.addSubview(numberView)

        NSLayoutConstraint.activate([
            numberView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            numberView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor)
        ])
    }
}

Additionally in collectionView(_:cellForItemAt:):

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "reusableCell", for: indexPath) as! CustomCollectionViewCell
    cell.numberValue = values[indexPath.row]

    return cell
}

And fingers crossed, the results:

fixed-scroll

It works! Now, when a collection view cell is going to be reused, it makes sure the previous label and constraints are destroyed before setting them up again. This prevents the overlapping labels seen in the earlier gif.

Summary

If you want to build your own programmatic custom UICollectionViewCells in the future, remembering these points will ensure success:

1) Don’t initialize cells yourself

Cells should be created or reused with dequeueReusableCell(withReuseIdentifier:for:).

2) Try not to override prepareForReuse (unless you need to)

Don’t override prepareForReuse to reset custom subviews - you should only be resetting attributes of the cell that are not related to content.

3) Clean up before reusing

Make sure to check if you need to clear existing subviews before setting them up again to avoid UI problems when scrolling - the above is just one example of how to achieve this.

Happy scrolling!

Have question or suggestion? Internet bird me at @captainbarbosa.