Codementor Events

Fast UICollectionView layout on a background thread

Published Jul 09, 2018

You can see an original post here
All of us want fast UICollectionView with 60 fps. And in the beginning in simple cases it’s easy. Then step by step the customer wants to improve our collection cells: display more and more information in our cells, make it dynamic, add more images or texts, etc.. And there are lots of tips for improving performance, e.g.: don’t use auto dimension, don’t use Constraints, make data model structs with pre-processed data instead of parsing JSON in configureCell method and so on. For improving our UI experience we can use really powerful libraries like Texture. But in Texture many things work like magic that we can’t control. And if you want to gain more control over this process I want to show you one more trick for moving layout computations into a background thread.
Usually when we calculate the size of a cell on the background and cache the result (for a fast heightForRowAtIndexPath method) we do something like this (at least I used to do this):

func heightForModel(dataModel: DataModel) -> CGFloat {
    let stringHeight = calculateHeightForText(dataModel.text)
    let commentsHeight = dataModel.comments.count > 0 ? Constant.commentsPanelHeight : 0
    return Constats.topPadding + Constants.titleHeight + stringHeight + commentsHeight
}

But this approach has two main disadvantages:

  1. We have two parts of layout code stored in two locations. The first part — in the height calculation function and the second — in thedidLayouSubviews method in a cell.

  2. We cache results of this function but we should also recalculate the layout on each call of the cellForRowAtIndexPath method. And by doing this we load our CPU more than we could.

The solution is to move all layout computations into the background and cache all these results. We’ll do this by introducing some viewModels (don’t pay much attention to the layout code below):

class LabelModel {
    var text: NSAttributedString
    var frame: CGRect
  
    func layout(with maxSize: CGSize) {
        let textContainer = NSTextContainer(size: maxSize)
        textContainer.maximumNumberOfLines = 0
        textContainer.lineFragmentPadding = 0
        let textStorage = NSTextStorage(attributedString: self.text)
        
        let layoutManager = NSLayoutManager()
        layoutManager.addTextContainer(textContainer)
        
        textStorage.addLayoutManager(layoutManager)
        
        self.frame = CGRect(origin: CGPoint(x: 0, y: 0),
                            size: layoutManager.usedRect(for: textContainer).size)
    }
}

And we’ll then apply this model to a UILabel:

extension UILabel {
    func applyModel(_ model: LabelModel) {
        self.attributedText = model.text
        self.frame = model.frame
    }
}

The main idea is the following: we calculate a frame on the background thread, save it, and then we apply it to the real label on the Main thread.

Let’s imagine that we have a cell with two labels: a text label and a likes counting label. For this cell viewModel could be similar to:

class TestCellModel {
    var textModel: LabelModel
    var likesModel: LabelModel
    var frame: CGRect
}

And the layout code would be:

func layout(with maxSize: CGSize) {
        
        let likesMaxSize = CGSize(width: maxSize.width,
                               height: CGFloat.greatestFiniteMagnitude)
        likesModel.layout(with: likesMaxSize)
        
        
        let textWidthRestriction = maxSize.width - likesModel.frame.width - Constants.interItemSpacing
        
        let textMaxSize = CGSize(width: textWidthRestriction,
                              height: CGFloat.greatestFiniteMagnitude)
        textModel.layout(with: textMaxSize)
        
        textModel.frame.origin = CGPoint(x: 0, y: Constants.verticalPadding)
        likesModel.frame.origin = CGPoint(x: textWidthRestriction + Constants.interItemSpacing,
                                          y: Constants.verticalPadding)
        
        self.frame = CGRect(x: 0,
                            y: 0,
                            width: maxSize.width,
                            height: textModel.frame.height + Constants.verticalPadding * 2)
}

In fact it doesn’t matter what layout code does. It’s a general concept.

Now that we have done all the preparations we only need to create our TestCell class:

class TestCell: UICollectionViewCell {

    private let textLabel: UILabel = UILabel()
    private let likesLabel: UILabel = UILabel()
    private var model: TestCellModel = TestCellModel.zero() // for avoiding optionals
    override init(frame: CGRect) {
        super.init(frame: frame)
        addSubview(textLabel)
        addSubview(likesLabel)
    }

    func applyModel(_ model: TestCellModel) {
        self.model = model
        setNeedsLayout() // for avoiding multiple setters in fast scroll
    }

    override func layoutSubviews() {
        textLabel.applyModel(self.model.textModel)
        likesLabel.applyModel(self.model.likesModel)
    }
}

So, in the end we have a model for calculation layout on the background. We can cache it and what’s even more useful — we can apply this model to our cell and it’s a really cheap operation. Also we have layout code stored only in one place.

This is just a concept but it works well in case we want to improve our collection performance and we have a lot of heavy layout operations.

I hope this approach will help you in your projects.

Example project is available on the Github

Thanks for reading.

Discover and read more posts from Aliaksandr Kantsevoi
get started