Hosting ViewControllers in Cells
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 post 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 post, I recommend doing so, as it presents 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 it, as the rest of this post won't make sense without it 😉.

The post itself is a few years old, written in Objective-C, so I converted it to Swift and integrated 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
}
}
- Each cell holds a reference to a (child) view controller's view via the
hostedView. Whenever thathostedViewproperty is set, the abovedidSetobserver code is triggered. - If
hostedViewwas previously set, that oldhostedViewis removed from the cell's view hierarchy. - If the current
hostedViewvalue is non-nil, it is added as a subview of the cell'scontentView. - 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 allows us to reset that cell. In the aboveprepareForReuse(),hostedViewis set tonil, so triggering its removal from the cell's 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 could be resolved by scrolling the collection view. However, I was quite dissatisfied with this 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. A view can only have one superview. If a view that already has a superview is added as a subview to a different view, the 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 with the following unintended associations:

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
}
Now, the cell will only remove the hostedView from its superview if that superview is part of the cell itself. 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
}
- The private
_hostedViewproperty assumes the responsibilities of the previoushostedViewproperty implementation and acts as the backing-store property to the newhostedViewproperty implementation. The_hostedViewnow actually holds a reference to the view controller's view, even though the outside world still thinks it ishostedViewthat holds the reference. Just like before, thedidSetobserver checks if theoldValueof the_hostedViewisn'tniland if it isn't, removes that view from the cell's view hierarchy if_hostedViewis still a subview. If the current value of_hostedViewis not nil,_hostedViewis added as a subview of the current cell. - The externally accessible
hostedViewproperty now has a customgetandset. - The
getonly returns the_hostedViewvalue if that view is still a subview of the cell. IfhostedViewisn't a subview, thegetsets_hostedViewto nil (which will cause it to be removed from this cell's view hierarchy) and returnsnil. Dropping the reference once something tries to accesshostedViewfeels a little strange. However, as thesuperviewproperty ofUIViewisn'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 thehostedViewhas a newsuperviewwithout querying thehostedViewand there is no point in querying thehostedViewuntil something tries to access it - so a little self-contained strangeness is the only viable option here. - The
settakes 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 -
_hostedViewshould only be directly accessed in eitherhostedViewor_hostedView; everywhere else, you should usehostedView.
You can see that by truthfully returning hostedView, we have added a lot of complexity to our cell. Still, none of that complexity leaks out. 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 to track 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.