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 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 😉.
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
}
}
- Each cell holds a reference to a (child) view controller's view via the
hostedView
. Whenever thathostedView
property is set, the abovedidSet
observer code is triggered. - If
hostedView
was previously set that oldhostedView
is removed from the cells view hierarchy. - If the current
hostedView
value 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 gives us the opportunity to reset that cell. In the aboveprepareForReuse()
,hostedView
is set tonil
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:
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
}
- The private
_hostedView
property assumes the responsibilities of the previoushostedView
property implementation and acts as backing-store property to the newhostedView
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 ishostedView
that holds the reference. Just like before there thedidSet
observer which checks if the theoldValue
of the_hostedView
isn'tnil
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. - The externally accessible
hostedView
property now has a customget
andset
. - The
get
only returns the_hostedView
value if that view is still a subview of the cell. IfhostedView
isn't a subview, theget
sets_hostedView
to nil (which will cause it to be removed from this cell's view hierarchy) and returnsnil
. Dropping the reference once something tries to accesshostedView
feels a little strange. However, as thesuperview
property ofUIView
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 thehostedView
has a newsuperview
without querying thehostedView
and there is no point in querying thehostedView
until something tries to access it - so a little self-contained strangeness is the only viable option here. - 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 eitherhostedView
or_hostedView
everywhere else should usehostedView
.
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
.