Hosting ViewControllers in Cells

Recently, I've been experiencing the iOS equivalent of Leonardo Dicaprio's Inception - putting a collection view inside a collection view. When exploring possible solutions I stumbled upon a very informative article by Soroush Khanlou and his suggestion that the best way to implement a collection view inside a collection view was by using child view controllers - with each child view controller implementing its own collection view and having its view added as a subview on one of the parent view controller's cells.

If you haven't read that article, I would recommend it - if only so that the rest of this article will make more sense 😉.

The article itself is a few years old and the examples were written in Objective-C so I converted it over to Swift and plugged the solution into my project. To begin with, it worked really well however occasionally a cell would forget its content. It was infrequent and could be resolved by scrolling the collection view. However I was pretty dissatisfied by experience and wanted to understand what was causing this UI breakdown.

At this point, the cell's hosted view implementation code looked like:

var hostedView: UIView? {
    didSet {
        guard let hostedView = hostedView else {
            return
        }

        hostedView.frame = contentView.bounds
        contentView.addSubview(hostedView)
    }
}

Each cell holds a reference to a view controller's view via the hostedView and whenever that hostedView property is set, the above method is triggered. This method checks that the value the hostedView is set to is non-nil and if so hostedView is added as a subview to the cell's contentView.

To improve performance collection views only create enough cells to fill the visible UI and then a few more to allow for smooth scrolling. When a cell scrolls off screen it is marked for reuse on a different index path. To help support this reusability each of the hosted view cells override the clean up method - prepareForReuse:

override func prepareForReuse() {
    super.prepareForReuse()

    hostedView?.removeFromSuperview()
    hostedView = nil
}

In the above method the hostedView is removed from its superview and set to nil. This ensures that multiple view controller's views can not be accidentally added to the same cell. Looking over that prepareForReuse method, you can see that removeFromSuperview is called to do the removing - what's interesting about removeFromSuperview is that it takes no arguments and instead uses the soon-to-be-removed view's superview value to determine what view to remove the caller from. As a view can only have one superview, if a view which already has a superview is added as a subview to a different view that original connection to the first superview will be broken and replaced with the new connection. For the most part this 1-to-M mapping between a superview and its subviews works just fine as most views once added as subviews do not tend to move around. However as discussed earlier, cells are designed to be reused and in this lies the root of the problem as in the current hostedView solution we can end up with the unintended associations shown below:

View controllers view connected to multiple cells

Here multiple cells are associated with the same view controller's view via each cell's hostedView (shown in purple) however only Cell C has the view controller's view as a subview (shown in green). Because of the multiple references kept to the view controller's view it's possible for Cell A, Cell B or Cell C to remove the hostedView from Cell C's view hierarchy by calling removeFromSuperview() in their own prepareForReuse method. Of course it was not intentional for multiple cells to have an active reference to a view controller's view if that view was no longer part of the cell's view hierarchy.

Thankfully once those left-over hostedView references were spotted, the solution for the bug became straightforward:

override func prepareForReuse() {
    super.prepareForReuse()

    if hostedView?.superview == contentView { //Make sure that hostedView hasn't been added as a subview to a different cell
        hostedView?.removeFromSuperview()
    } 

    hostedView = nil
}

Before removing the hostedView, its superview is checked to see if it is actually a subview of the cell's contentView and only if it is, is the hostedView removed from its superview. With that simple if statement the bug was gone and the UI started behaving itself properly 100% of the time 🤩.

Seeing the 🐛 for yourself

If you want to see this bug in the wild, you can download the example project from my repo. In that example project I've added a print statement in prepareForReuse for when the view controller's view isn't a subview on that cell anymore so that you can easily see when that bug would have happened by watching the console. If you want to see the bug's impact on the UI, just comment out the superview check in the prepareForReuse method in the ColorTableViewCell class.

The example uses a table view for simplicity but the solution would work for a collection view as well.

What do you think? Let me know by getting in touch on Twitter - @wibosco