Hosting ViewControllers in Cells

April 11th, 2018
#ui

Recently, I've been experiencing the iOS equivalent of the movie Inception - putting a collection view inside a collection view. While exploring possible solutions, I stumbled upon this 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 recommend that you do as it sets out the argument for why you would want to put view controllers inside cells very well. And even if you don't need to be convinced I would still recommend as the rest of this article won't make sense if you don't 😉.

Photo of stained glass spiral

The article itself is a few years old with the examples are in Objective-C, so I converted it over to Swift and plugged the solution into my project. I ended with the following collection view cell subclass:

class HostedViewCollectionViewCell: UICollectionViewCell {

    // MARK: - HostedView

    // 1
    weak var hostedView: UIView? {
        didSet {
          // 2
          if let oldValue = oldValue {
              oldValue.removeFromSuperview()
          }

          // 3
          if let hostedView = hostedView {
              hostedView.frame = contentView.bounds
              contentView.addSubview(hostedView)
          }
        }
    }

    // MARK: - Reuse

    // 4
    override func prepareForReuse() {
        super.prepareForReuse()

        hostedView = nil
    }
}
  1. Each cell holds a reference to a (child) view controller's view via the hostedView. Whenever that hostedView property is set, the above didSet observer code is triggered.
  2. If hostedView was previously set that old hostedView is removed from the cells view hierarchy.
  3. If the current hostedView value is non-nil, it is added as a subview of the cell's contentView.
  4. To improve performance, a collection view will 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. prepareForReuse() is called just before the cell is reused and gives us the opportunity to reset that cell. In the above prepareForReuse(), hostedView is set to nil so triggering its removal from the cells view hierarchy.

To begin with, this solution worked well. However, I started noticing that occasionally a cell would forget its content. It was infrequent and would be resolved by scrolling the collection view. However, I was pretty dissatisfied by experience and wanted to understand what was causing this UI breakdown.

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 is broken and replaced with this 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, cells are designed to be reused. The reusable nature of cells lies at the root of my cells forgetfulness. By using the solution above we end up the following unintended associations:

A diagram showing how multiple cells can hold a reference to the same view controller's view but that view can only have one cell as a superview

The diagram above shows how multiple cells can be associated (shown in purple) with the same view controller's view, but only one of those cells has the view of ViewController as a subview (shown in green). Because of the multiple references kept to the same view of ViewController it's possible for any of Cell A, Cell B or Cell C to remove the hostedView from Cell C 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.

Once those unintended left-over hostedView references were spotted, the solution for the bug became straightforward - only remove the hostedView if it is still in the cell's view hierarchy:

class HostedViewCollectionViewCell: UICollectionViewCell {
    var hostedView: UIView? {
        didSet {
            if let oldValue = oldValue {
                if oldValue.isDescendant(of: self) { //Make sure that hostedView hasn't been added as a subview to a different cell
                    oldValue.removeFromSuperview()
                }
            }

            //Omitted rest of observer
        }
    }

    //Omitted methods
}

So now the cell will only remove the hostedView from its superview if that superview is part of the cell. This additional if statement addresses the forgetfulness that I was seeing. However, if you queried hostedView on Cell A or Cell B from the above diagram you would still get back a reference to the view that Cell C. With the above if statement, we have only resolved part of the bug. Let's make the cell only return a hostedView value if that hostedView is actually part of its view hierarchy:

class HostedViewCollectionViewCell: UICollectionViewCell {

    // MARK: - HostedView

    // 1
    private weak var _hostedView: UIView? {
        didSet {
            if let oldValue = oldValue {
                if oldValue.isDescendant(of: self) { //Make sure that hostedView hasn't been added as a subview to a different cell
                    oldValue.removeFromSuperview()
                }
            }

            if let _hostedView = _hostedView {
                _hostedView.frame = contentView.bounds
                contentView.addSubview(_hostedView)
            }
        }
    }

    // 2
    weak var hostedView: UIView? {
        // 3
        get {
            guard _hostedView?.isDescendant(of: self) ?? false else {
                _hostedView = nil
                return nil
            }

            return _hostedView
        }
        //4
        set {
            _hostedView = newValue
        }
    }

    //Omitted methods
}
  1. The private _hostedView property assumes the responsibilities of the previous hostedView property implementation and acts as backing-store property to the new hostedView property implementation. The _hostedView is what now actually holds a reference to the view controller's view even though the outside world still thinks it is hostedView that holds the reference. Just like before there the didSet observer which checks if the the oldValue of the _hostedView isn't nil and if it isn't, removes that view from the cell's view hierarchy if _hostedView is still a subview. If the current value of _hostedView is not nil, _hostedView is added as a subview of the current cell.
  2. The externally accessible hostedView property now has a custom get and set.
  3. The get only returns the _hostedView value if that view is still a subview of the cell. If hostedView isn't a subview, the get sets _hostedView to nil (which will cause it to be removed from this cell's view hierarchy) and returns nil. Dropping the reference once something tries to access hostedView feels a little strange. However, as the superview property of UIView isn't KVO compliant (and we don't want to get involved in the dark-arts of method swizzling to make it so) there is no way for us to know that the hostedView has a new superview without querying the hostedView and there is no point in querying the hostedView until something tries to access it - so a little self-contained strangeness is the only viable option here.
  4. The set takes the new value and assigns it to the backing-store property.

With a backing-store property it is essential that you practice good access hygiene - _hostedView should only be directly accessed in either hostedView or _hostedView everywhere else should use hostedView.

You can see that by truthfully returned hostedView we have added a lot of complexity to our cell, but none of that complexity leaks out and we can have confidence that hosting a view controller's view won't lead to unintended consequences.

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 logging 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, comment out the isDescendant(of:_) check, in HostedViewCollectionViewCell.

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