I make a lot of hay about how to break view controllers up and how view controllers are basically evil, but today I’m going to approach the problem in a slightly different way. Instead of rejecting view controllers, what if we embraced them? We could make lots and lots of small view controllers, instead of lots of lots of small plain objects. After all, Apple gives us good ways to compose view controllers. What if we “leaned in” to view controllers? What benefits could we gain from such a setup?
I know a few people who do a subset of this. Any time there’s a meaningful collection of subviews, you can create a view controller out of those, and compose those view controllers together. This is a worthwhile technique, but today’s post will use a new type of view controller — one that defines a behavior — and show you how to compose them together.
Consider analytics. Often, I’ve seen analytics handled in a BaseViewController
class:
@implementation BaseViewController: UIViewController
//...
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
[AnalyticsSingleton registerImpression:NSStringFromClass(self)];
}
//...
@end
You could have a lot of different behaviors in this base class. I’ve seen base view controllers with a few thousand lines of shared behavior and helpers. (I’ve seen it in Rails ActionController
s too.) But we won’t always need all this behavior, and sticking this code in every class breaks encapsulation, draws in tons of dependencies, and generally just grosses everyone out.
We have a general principle that we like to follow: prefer composition instead. Luckily, Apple gives us a great way to compose view controllers, and we’ll get access to the view lifecyle methods too, for free! Even if your view controller’s view is totally invisible, it’ll still get the appearance callbacks, like -viewDidAppear:
and -viewWillDisappear
.
To add analytics to your existing view controllers as a composed behavior rather than something in your superclass, first, set up the behavior as a view controller:
@implementation AnalyticsViewController
- (instancetype)initWithName:(NSString *)name {
self = [super init];
if (!self) return nil;
_name = name;
self.view.alpha = 0.0;
return self;
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
[AnalyticsSingleton registerImpression:self.name];
}
@end
Note that the alpha
of this view controller’s view is set to 0. It won’t be rendered, but it will still exist. We now have a simple view controller that we can add as a child, we need a way to do so easily, to any view controller. Fortunately, for this, we can simply extend the UIViewController
class:
@implementation UIViewController (Analytics)
- (void)configureAnalyticsWithName:(NSString *)name {
AnalyticsViewController *analytics = [[AnalyticsViewController alloc] initWithName:name];
[self addChildViewController:analytics];
[analytics didMoveToParentViewController:self];
[self.view addSubview:analytics.view];
}
@end
We can call -configureAnalyticsWithName:
anywhere in our primary view controller, and we’ll instantly get our view tracking with one line of code. It’s encapsulated in a very straightforward way. It’s easily composed into any view controller, including view controllers that we don’t own! Since the method -configureAnalyticsWithName:
is available on every single view controller, we can easily add behavior without actually being inside of the class in question. It’s a very powerful technique, and it’s been hiding under our noses this whole time.
Let’s look at another example: loading indicators. This is something that’s typically handled globally, with something like SVProgressHUD
. Because this is a singleton, every view controller (every object!) has the ability to add and remove the single global loading indicator. The loading indicator doesn’t have any state (besides visible and not-visible), so it doesn’t know to disappear when the current view is dismissed and the context changes. Ideally, we’d like the ability to have a loading indicator whenever we need one, but not otherwise, and to be able to turn it on and off with minimal code. We can approach this problem in the same way as the analytics view controller.
@implementation LoadingViewController
- (void)loadView {
LoadingView *loadingView = [[LoadingView alloc] init];
loadingView.hidden = YES;
loadingView.label.text = @"Posting...";
self.view = loadingView;
}
- (LoadingView *)loadingView {
return (LoadingView *)self.view;
}
- (void)show {
self.loadingView.alpha = 1.0;
[self.loadingView startAnimating];
}
- (void)hide {
self.loadingView.alpha = 0.0;
[self.loadingView stopAnimating];
}
@end
And our extension to UIViewController a little more complex this time. Since we don’t have any configuration information, like the name
in the analytics example, we can lazily add the loader the first time it needs to be used.
@implementation UIViewController (Loading)
- (LoadingViewController *)createAndAddLoader {
LoadingViewController *loading = [[LoadingViewController alloc] init];
[self addChildViewController:loading];
[loading didMoveToParentViewController:self];
[self.view addSubview:loading.view];
return loading;
}
- (LoadingViewController *)loader {
for (UIViewController *viewController in self.childViewControllers) {
if ([viewController isKindOfClass:[LoadingViewController class]]) {
return (LoadingViewController *)viewController;
}
}
return [self createAndAddLoader];
}
@end
Again, we see similar benefits. The loader is no longer a global; instead, each view controller adds its own loader as needed. The loader can be shown and hidden with [self.loader show]
and [self.loader hide]
. You also don’t have to explicitly add the behavior (a loader) in this example.
We get the benefit of simple invocations and well-factored code. Other solutions to this problem require you to use globals or subclass from one common view controller, whereas this does not.
This example doesn’t need access to the view lifecycle methods like the other ones. It only needs access to the view, which it gets just from being a child view controller. (If you wanted, you could also add more state, like a incrementing and decrementing counter for the number of in-flight network requests.)
Another example of a common view controller behavior that we would love to factor out is error presentation. As of iOS 8, UIAlertView
is deprecated in favor of UIAlertController
, which requires access to a view controller. In the Backchannel SDK, I use a class called BAKErrorPresenter
that is initialized with a view controller for presenting the error. Instead, what if the error presenter was a view controller?
@implementation ErrorPresenterViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.alpha = 0.0
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
self.isVisible = YES;
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
self.isVisible = NO;
}
- (UIAlertAction *)okayAction {
return [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil];
}
- (void)present:(NSError *)error {
if (!self.isVisible) { return; }
UIAlertController *alert = [UIAlertController alertControllerWithTitle:error.localizedDescription message:error.localizedFailureReason preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:self.okayAction];
[self presentViewController:alert animated:YES completion:nil];
}
@end
Note that the error presenter can maintain any state it needs, such as isVisible
from the lifecycle methods, and this state doesn’t gunk up the primary view controller.
I’ll leave out the UIViewController
extension here, but it would function similarly to the loading indicator, lazily loading an error presenter when one is needed. With this code, all you need to present an error is:
[self.errorPresenter present:error];
How much simpler could it be? And we didn’t even have to sacrifice any programming principles.
For our last example, I want to look at a reusable component that’s highly dependent on the view appearance callbacks. Keyboard management is something that’s typically that needs to know when the view is on screen. Normally, if you break this out into it’s own object, you’ll have to manually invoke the appearance methods. Instead, you get that for free!
@implementation KeyboardManagerViewController
- (instancetype)initWithScrollView:(UIScrollView *)scrollView {
self = [super init];
if (!self) return nil;
_scrollView = scrollView;
self.alpha = 0.0
return self;
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardAppeared:) name:UIKeyboardDidShowNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(keyboardDisappeared:) name:UIKeyboardWillHideNotification object:nil];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardDidShowNotification object:nil];
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillHideNotification object:nil];
}
- (void)keyboardAppeared:(NSNotification *)note {
CGRect keyboardRect = [[note.userInfo objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
self.oldInsets = self.scrollView.contentInset;
UIEdgeInsets contentInsets = UIEdgeInsetsMake(self.oldInsets.top, 0.0f, CGRectGetHeight(keyboardRect), 0.0f);
self.scrollView.contentInset = contentInsets;
self.scrollView.scrollIndicatorInsets = contentInsets;
}
- (void)keyboardDisappeared:(NSNotification *)note {
self.scrollView.contentInset = self.oldInsets;
self.scrollView.scrollIndicatorInsets = self.oldInsets;
}
@end
This is a simple and almost trivial implementation of a keyboard manager. Yours might be more robust. The principle, however, is sound. Encode your behaviors into tiny, reusable view controllers, and add them to your primary view controller as needed.
Using this technique, you can avoid the use of global objects, tangled view controllers, long inheritance heirarchies, and other code smells. What else can you make? A view controller responsible purely for refreshing network data whenever the view appears? A view controller for validating the data in the form view? The possiblities are endless.