There comes a time in every iOS developer’s life when she wants to put a view controller inside of a table view cell. For all the ragging I do on view controllers, they’re a useful tool for wrangling complex views, and it’s easy to imagine cases where you would want to stack them on top of each other. Another common use case is putting collection views inside of table view cells. Ideally, that inner collection view will have its own controller, so that the outer table view controller isn’t polluted by the binding of view and data for each collection view cell.
Because UIKit has lots of little hooks in it, where components talk to other components behind-the-scenes, we need to make sure we use the UIViewController
containment APIs for our child view controllers. Things will silently fail or act weird if we don’t.
I’ll be talking mostly about table view and their cells in this post, but everything is generalizable to collection views as well.
To keep things simple, the view controller should be the only content inside the cell. Trying to manage regular views next to a view controller’s view will result in needless complexity. With one view controller and only one view controller in each cell, layout (at the cell level) is as simple as
self.viewController.view.frame = self.contentView.bounds;
And the view controller can handle any layout internal to itself. We can also push height calculations down to the view controller.
There are two approaches: we can either a) have each table cell hold on to a view controller or b) hold the view controllers at the controller level.
A controller for each cell
If we put a view controller in each cell, then we might lazy load it inside the cell.
- (SKContentViewController *)contentViewController {
if (!_contentViewController) {
SKViewController *contentViewController = [[SKContentViewController alloc] init];
self.contentViewController = contentViewController;
}
return _contentViewController;
}
Note that we didn’t add the controller’s view as a subview of our cell’s contentView
. When it comes time to configure this cell, in -cellForRowAtIndexPath:
, we can pass our model to this controller and have it configure itself with the new content. Since these cells are reused, your controller has to be designed to completely reset itself anytime its model is changed.
UITableView
gives us hooks for before and after a cell is displayed in the table view, and before and after it is removed from display. That’s when we want to add the cell’s view controller to our parent table view controller and the view controller’s view to the cell.
- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
[cell addViewControllerToParentViewController:self];
}
- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
[cell removeViewControllerFromParentViewController];
}
Inside the cell class, implementations for these methods are simple view controller containment.
- (void)addViewControllerToParentViewController:(UIViewController *)parentViewController {
[parentViewController addChildViewController:self.contentViewController];
[self.contentViewController didMoveToParentViewController:parentViewController];
[self.contentView addSubview:self.contentViewController.view];
}
- (void)removeViewControllerFromParentViewController {
[self.contentViewController.view removeFromSuperview];
[self.contentViewController willMoveToParentViewController:nil];
[self.contentViewController removeFromParentViewController];
}
The subview is added after the view controller is added as a child, to ensure -viewWillAppear:
and company are called correctly. The table view’s willDisplayCell:
method corresponds nicely with the appearance methods ( -viewWillAppear:
and -viewDidAppear:
) and -didEndDisplayingCell:
corresponds nicely with the disappearance methods, so these are the right time to perform our containment.
Holding the view controllers in the parent
Each cell have its own view controller works fine, but it feels a little weird. In Cocoa’s form of MVC, the models and views aren’t supposed to know about the controllers they’re used by. Having a cell (which is ultimately a UIView
) hold on to a controller inverts that command hierarchy. To address this, we can hold on to all the child view controllers at the parent’s level, in the table view controller.
We can make this work in two ways. We can pre-allocate a view controller (and thus a view) for each item that we want to display in our table (which is easier), or allocate the view controllers as needed, and recycle them, the same way that UITableView
handles cells (which is harder). Let’s start with the easy way first. When the iPhone first came out, devices were extremely strapped for RAM and couldn’t afford to generate views for each row in a table. Now, our devices have much more RAM, so you might not need to recycle views if you’re only displaying a few rows.
- (void)setupChildViewControllers {
self.contentViewControllers = [self.modelObjects arrayByTransformingObjectsUsingBlock:^id(SKModel *model) {
SKViewController *contentViewController = [[SKContentViewController alloc] initWithModel:model];
[self addChildContentViewController:contentViewController];
return contentViewController;
}];
}
- (void)addChildContentViewController:(UIViewController *)childController {
[self addChildViewController:childController];
[childController didMoveToParentViewController:self];
}
(I’m using Objective-Shorthand’s -arrayByTransformingObjectsUsingBlock:
here, which is longhand for -map:
.)
Once you have your view controllers, you can pass their views your cell in -cellForRowAtIndexPath:
.
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = //make a cell
SKViewController *viewController = self.contentViewControllers[indexPath.row];
cell.hostedView = contentViewController.view;
return cell;
}
And in your cell, you can take that hosted view, and add it as a subview, and clear it upon reuse.
- (void)setHostedView:(UIView *)hostedView {
_hostedView = hostedView;
[self.contentView addSubview:hostedView];
}
- (void)prepareForReuse {
[super prepareForReuse];
[self.hostedView removeFromSuperview];
self.hostedView = nil;
}
This is everything you need for the easy way. To add view controller recycling, you need an NSMutableSet
of unusedViewControllers
and an NSMutableDictionary
of viewControllersByIndexPath
.
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = //make a cell
SKViewController *viewController = [self recycledOrNewViewController];
viewController.model = [self.dataSource objectAtIndexPath:indexPath];
self.viewControllersByIndexPath[indexPath] = viewController;
cell.hostedView = contentViewController.view;
return cell;
}
- (UIViewController *)recycledOrNewViewController {
if (self.unusedViewControllers.count > 0) {
UIViewController *viewController = [self.unusedViewControllers anyObject];
[self.unusedViewControllers removeObject:viewController];
return viewController;
}
SKViewController *contentViewController = [[SKContentViewController alloc] init];
[self addChildViewController:contentViewController];
return contentViewController;
}
- (void)tableView:(UITableView *)tableView didEndDisplayingCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
UIViewController *viewController = self.viewControllersByIndexPath[indexPath];
[self.viewControllersByIndexPath removeObjectForKey:indexPath]
[self.unusedViewControllers addObject:viewController];
}
- (NSMutableSet *)unusedViewControllers {
if (!_unusedViewControllers) {
self.unusedViewControllers = [NSMutableSet set];
}
return _unusedViewControllers;
}
- (NSMutableDictionary *)viewControllersByIndexPath {
if (!_viewControllersByIndexPath) {
self.viewControllersByIndexPath = [NSMutableDictionary dictionary];
}
return _viewControllersByIndexPath;
}
There are three important things here: first, unusedViewControllers
holds on to any view controllers that are waiting to be reused. Second, viewControllersByIndexPath
holds on to any view controllers that are in use. (We have to hold on to them; otherwise, they’ll deallocate.) Lastly, the cell only touches the hostedView
of the view controller, fulfilling our earlier constraint where views can’t know about view controllers.
These are the two best ways I’ve found to work with UIViewController
objects in cells. I would love to hear if there are any techniques I missed.