In this tutorial, we'll implement a minimalistic version of the Facebook/Path-style UI. The objective will be to understand how to utilize view controller containment in order to implement custom flow in your app.
Theoretical Overview
View controllers are an essential part of any iOS application, no matter how small, big, simple, or complex. They provide the "glue-logic" between the data model of your app and the user interface.
Broadly speaking, there are two kinds of view controllers:
- Content view controllers: These are responsible for displaying and managing visible content.
- Container controllers: These manage content view controllers, and they're responsible for the overall structure and flow of the app.
A container controller may have some visible component of its own, but basically functions as a host for content view controllers. Container controllers serve to "traffic" the comings and goings of content view controllers.
UINavigationController
, UITabBarController
and UIPageViewController
are examples of container view controllers that ship with the iOS SDK. Consider how the three are different in terms of the application flows that they give rise to. The navigation controller is great for a drill-down type app, where the selection the user makes in one screen affects what choices he's presented with in the next screen. The tab bar controller is great for apps with independent pieces of functionality, allowing convenient toggling by simply pressing a tab button. Finally, the page view controller presents a book metaphor, allowing the user to flip back and forth between pages of content.
The key thing to keep in mind here is that an actual screenful of content being presented through any of these container view controllers itself needs to be managed, both in terms of the data it is derived from (the model) and the on-screen presentation (the view), which would again be the job of a view controller. Now we're talking about content view controllers. In some apps, particularly on the iPad because its larger screen allows more content to be shown at once, different views on the screen might even need to be managed independently. This requires multiple view controllers onscreen at once. All of this implies that the view controllers in a well-designed app should be implemented in a hierarchical manner with both container and content view controllers playing their respective roles.
Prior to iOS 5, there were no means of declaring a hierarchical (i.e., parent-child) relationship between two view controllers and therefore no "proper" way of implementing a custom application flow. One either had to make do with the built-in types, or do it in a haphazard way, which basically consisted of sticking views managed by one view controller into the view hierarchy of the view managed by another view controller. This would create inconsistencies. For example, a view would end up being in the view hierarchy of two controllers without either of these controllers acknowledging the other, which sometimes leads to strange behaviour. Containment was introduced in iOS 5 and refined slightly in iOS 6, and it allows the notion of parent and child view controllers in a hierarchy to be formalized. Essentially, correct view controller containment demands that if view B is a subview (child) of view A and if they're not under the management of the same view controller, then B's view controller must be made A's view controller's child.
You might ask whether there is any concrete benefit offered by view controller containment besides the advantage of the hierarchical design we discussed. The answer is yes. Keep in mind that when a view controller comes on screen or goes away, we might need to set up or tear down resources, clean up, fetch or save information from/to the file system. We all know about appearance callbacks. By explicitly declaring the parent-child relationship, we ensure that the parent controller will forward callbacks to its children whenever one comes on or goes off the screen. Rotation callbacks need to be forwarded too. When the orientation changes, all the view controllers on the screen need to know so they can adjust their content appropriately.
What does all this imply, in terms of code? View controllers have an NSArray
property called childViewControllers
and our responsibilities include adding and removing child view controllers to and from this array in the parent by calling appropriate methods. These methods include addChildViewController
(called on the parent) and removeFromParentViewController
(called on the child) when we seek to make or break the parent-child relationship. There are also a couple of notification messages sent to the child view controller at the start and at the end of the addition/removal process. These are willMoveToParentViewController:
and didMoveToParentViewController:
, sent with the appropriate parent controller as argument. The argument is nil
, if the child is being removed. As we'll see, one of these messages will be sent for us automatically while the other will be our responsibility to send. This will depend on whether we're adding or removing the child. We'll study the exact sequence shortly when we implement things in code. The child controller can respond to these notifications by implementing the corresponding methods if it needs to do something in preparation of these events.
We also need to add/remove the views associated with the child view controller to the parent's hierarchy, using methods such as addSubview:
or removeFromSuperview
), including performing any accompanying animations. There's a convenience method (-)transitionFromViewController:toViewController:duration:options:animations:completion:
that allows us to streamline the process of swapping child view controllers on-screen with animations. We'll look at the exact details when we write the code - which is next!
1. Creating a New Project
Create a new iOS app in Xcode based on the "Empty Application" template. Make it an iOS App with ARC enabled. Call it VCContainmentTut.
2. Implementing the Container View Controller
Create a new class called RootController
. Make it a UIViewController
subclass. Ensure that any checkboxes are deselected. This will be our container view controller subclass.
Replace the code in RootViewController.h with the following.
#import <UIKit/UIKit.h> @interface RootController : UIViewController<UITableViewDataSource, UITableViewDelegate> // (1) - (id)initWithViewControllers:(NSArray *)viewControllers andMenuTitles:(NSArray *)titles; // (2) @end
Our container controller will have a table view that functions as our menu, and tapping any cell will replace the currently visible view controller by the one selected through the user's tap.
Referring to the points in the code,
- Our root controller doubles as the menu's (i.e., the table view's) delegate and datasource.
- We are offering an extremely simple API (as far as our container class user is concerned) consisting of one initializer that takes a list of view controllers that our root controller shall contain, and a list of strings representing the titles for each view controller in the menu. This is so we can concentrate on the basics. Once you understand these you can make the API as customizable as you like.
Let's take a peek ahead to see what our finished product will look like so that you have a mental picture to associate the implementation with.
It will help to realize that our container view controller is quite similar to a tab view controller. Each item in the menu corresponds to an independent view controller. The difference between our "sliding menu" controller and the tab controller is visual for the most part. The phrase "sliding menu" is a bit of a misnomer because it's actually the content view controller that slides to hide or reveal the menu underneath.
Moving on to the implementation, replace all the code in RootController.m with the following code.
#define kExposedWidth 200.0 #define kMenuCellID @"MenuCell" #import "RootController.h" @interface RootController() @property (nonatomic, strong) UITableView *menu; @property (nonatomic, strong) NSArray *viewControllers; @property (nonatomic, strong) NSArray *menuTitles; @property (nonatomic, assign) NSInteger indexOfVisibleController; @property (nonatomic, assign) BOOL isMenuVisible; @end @implementation RootController - (id)initWithViewControllers:(NSArray *)viewControllers andMenuTitles:(NSArray *)menuTitles { if (self = [super init]) { NSAssert(self.viewControllers.count == self.menuTitles.count, @"There must be one and only one menu title corresponding to every view controller!"); // (1) NSMutableArray *tempVCs = [NSMutableArray arrayWithCapacity:viewControllers.count]; self.menuTitles = [menuTitles copy]; for (UIViewController *vc in viewControllers) // (2) { if (![vc isMemberOfClass:[UINavigationController class]]) { [tempVCs addObject:[[UINavigationController alloc] initWithRootViewController:vc]]; } else [tempVCs addObject:vc]; UIBarButtonItem *revealMenuBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Menu" style:UIBarButtonItemStylePlain target:self action:@selector(toggleMenuVisibility:)]; // (3) UIViewController *topVC = ((UINavigationController *)tempVCs.lastObject).topViewController; topVC.navigationItem.leftBarButtonItems = [@[revealMenuBarButtonItem] arrayByAddingObjectsFromArray:topVC.navigationItem.leftBarButtonItems]; } self.viewControllers = [tempVCs copy]; self.menu = [[UITableView alloc] init]; // (4) self.menu.delegate = self; self.menu.dataSource = self; } return self; } - (void)viewDidLoad { [super viewDidLoad]; [self.menu registerClass:[UITableViewCell class] forCellReuseIdentifier:kMenuCellID]; self.menu.frame = self.view.bounds; [self.view addSubview:self.menu]; self.indexOfVisibleController = 0; UIViewController *visibleViewController = self.viewControllers[0]; visibleViewController.view.frame = [self offScreenFrame]; [self addChildViewController:visibleViewController]; // (5) [self.view addSubview:visibleViewController.view]; // (6) self.isMenuVisible = YES; [self adjustContentFrameAccordingToMenuVisibility]; // (7) [self.viewControllers[0] didMoveToParentViewController:self]; // (8) } - (void)toggleMenuVisibility:(id)sender // (9) { self.isMenuVisible = !self.isMenuVisible; [self adjustContentFrameAccordingToMenuVisibility]; } - (void)adjustContentFrameAccordingToMenuVisibility // (10) { UIViewController *visibleViewController = self.viewControllers[self.indexOfVisibleController]; CGSize size = visibleViewController.view.frame.size; if (self.isMenuVisible) { [UIView animateWithDuration:0.5 animations:^{ visibleViewController.view.frame = CGRectMake(kExposedWidth, 0, size.width, size.height); }]; } else [UIView animateWithDuration:0.5 animations:^{ visibleViewController.view.frame = CGRectMake(0, 0, size.width, size.height); }]; } - (void)replaceVisibleViewControllerWithViewControllerAtIndex:(NSInteger)index // (11) { if (index == self.indexOfVisibleController) return; UIViewController *incomingViewController = self.viewControllers[index]; incomingViewController.view.frame = [self offScreenFrame]; UIViewController *outgoingViewController = self.viewControllers[self.indexOfVisibleController]; CGRect visibleFrame = self.view.bounds; [outgoingViewController willMoveToParentViewController:nil]; // (12) [self addChildViewController:incomingViewController]; // (13) [[UIApplication sharedApplication] beginIgnoringInteractionEvents]; // (14) [self transitionFromViewController:outgoingViewController // (15) toViewController:incomingViewController duration:0.5 options:0 animations:^{ outgoingViewController.view.frame = [self offScreenFrame]; } completion:^(BOOL finished) { [UIView animateWithDuration:0.5 animations:^{ [outgoingViewController.view removeFromSuperview]; [self.view addSubview:incomingViewController.view]; incomingViewController.view.frame = visibleFrame; [[UIApplication sharedApplication] endIgnoringInteractionEvents]; // (16) }]; [incomingViewController didMoveToParentViewController:self]; // (17) [outgoingViewController removeFromParentViewController]; // (18) self.isMenuVisible = NO; self.indexOfVisibleController = index; }]; } // (19): - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 1; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.menuTitles.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kMenuCellID]; cell.textLabel.text = self.menuTitles[indexPath.row]; return cell; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [self replaceVisibleViewControllerWithViewControllerAtIndex:indexPath.row]; } - (CGRect)offScreenFrame { return CGRectMake(self.view.bounds.size.width, 0, self.view.bounds.size.width, self.view.bounds.size.height); } @end
Now for an explanation of the code. The parts that I've highlighted for emphasis are especially relevant to the containment implementation.
- First, the initializer does a simple check to ensure that each view controller has been given a menu title. We haven't done any type checking to ensure that each of the two arrays passed to the initializer contain the right kind of objects,
UIViewController
andNSString
types respectively. You might consider doing it. Note that we're maintaining arrays for each of these, calledviewControllers
, andmenuTitles
. - We want there to be a button that, when tapped, displays or hides the menu. My simple solution as to where the button should be was to put every view controller we received from the initializer inside a navigation controller. This gives us a free navigation bar that a button can be added to, unless the passed in view controller was already a navigation controller, in which case we don't do anything extra.
- We create a bar button item that triggers the menu's appearance or hiding by sliding the view controller that's currently visible. We add it to the navigation bar by moving any existing buttons on the navigation bar to the right. We do this because the added view controller is already a navigation controller with pre-existing bar buttons.
- We instantiate our menu as a table view and assign the root controller itself as the delegate and data source.
- In
viewDidLoad
, after configuring and adding the menu table view to our root controller's view, we usher into our app the first view controller in theviewControllers
array. By sending theaddChildViewController:
message to our root controller, we carry out our first containment-related responsibility. You should know that this causes the messagewillMoveToParentViewController:
to be called on the child controller. - Note that we explicitly need to add our child controller's view to the parent's view hierarchy!
- We set the menu to be visible initially and call a method that adjusts the visible content view controller's frame, taking the menu's visibility into account. We'll look into this method's details shortly.
- Once the child view controller's view is sitting comfortably in the parent's view hierarchy, we send the didMoveToParentViewController message to the added child controller, with
self
, the RootController instance, as the argument. In our child controller, we can implement this method if we need to. - A simple method connected to our menu bar button's action that toggles the menu's visibility by adjusting the overlaying view controller's view appropriately.
- As the name indicates,
adjustContentFrameAccordingToMenuVisibility
lets us adjust the content view controller's frame to tell us whether the menu is hidden or not. If yes, then it overlays the superview. Otherwise, it is shifted to the right bykExposedWidth
. I've set that to 200 points. - Again, as clearly indicated by the name,
replaceVisibleViewControllerWithViewControllerAtIndex
allows us to swap out view controllers and the corresponding views from the hierarchy. To pull off our animation, which consists of sliding the replaced view controller offscreen to the right and then bringing in the replacement controller from the same place, we define some rectangular frames. willMoveToParentViewController with nil
. Once we complete this step, this view controller will cease to receive appearance and rotation callbacks from the parent. This makes sense because it's no longer an active part of the app.- We add the incoming view controller as a child to the root controller, similar to what we did in the beginning.
- We begin ignoring user interaction events to let our view controller swap over smoothly.
- This convenience method allows us to animate the removal of the outgoing controller and the arrival of the incoming one while performing the required sequence of events involved in the child view controller addition and removal process. We animate the outgoing VC's view to slide offscreen to the right, and after the animation is completed we remove it from the view hierarchy. We then animate the incoming view controller to slide in from the same place offscreen and occupy the place previously taken up by the outgoing controller's view.
- We enable our app to accept incoming interaction events, since our view controller swap has been completed.
- We notify the incoming view controller that it's been moved to the container controller by sending it the
didMoveToParentViewController
message with self as the argument. -
We remove the outgoing controller from the container controller by sending it the
removeFromParentViewController
message. You should know thatdidMoveToParentViewController:
withnil
as an argument gets sent for you. - We implement the menu table view's delegate and data source protocol methods, which are pretty straightforward. This includes triggering the view controller swapping step (11) when a new item's cell is tapped in the menu via the
-tableView:didSelectRowAtIndexPath:
method.
You may have found the sequence of calls related to controller containment a bit confusing. It helps to summarize.
-
When adding a child view controller to a parent:
- Call
addChildViewController:
on the parent with the child as the argument. This causes the messagewillMoveToParentViewController:
to be sent to the child with the parent as the argument. - Add the child's view as a subview of the parent's view.
- Explicitly call
didMoveToParentViewController:
on the child with the parent as the argument.
-
When removing a child view controller from its parent:
- Call
willMoveToParentViewController:
on the child withnil
as the argument. - Remove the child's view from its superview.
- Send
removeFromParentViewController
to the child. The causes the messagedidMoveToParentViewController
withnil
as the argument to be sent to the child on your behalf.
3. Testing
Let's test the different types of view controllers added to our root controller! Create a new subclass of UIViewController
called ViewController
, keeping any options unchecked.
Replace the code in ViewController.m with the following code.
#import "ViewController.h" @interface ViewController () @end @implementation ViewController - (void)willMoveToParentViewController:(UIViewController *)parent { NSLog(@"%@ (%p) - %@", NSStringFromClass([self class]), self, NSStringFromSelector(_cmd)); } - (void)didMoveToParentViewController:(UIViewController *)parent { NSLog(@"%@ (%p) - %@", NSStringFromClass([self class]), self, NSStringFromSelector(_cmd)); } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; NSLog(@"%@ (%p) - %@", NSStringFromClass([self class]), self, NSStringFromSelector(_cmd)); } - (void)viewDidAppear:(BOOL)animated { [super viewDidAppear:animated]; NSLog(@"%@ (%p) - %@", NSStringFromClass([self class]), self, NSStringFromSelector(_cmd)); } - (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration { [super willRotateToInterfaceOrientation:toInterfaceOrientation duration:duration]; NSLog(@"%@ (%p) - %@", NSStringFromClass([self class]), self, NSStringFromSelector(_cmd)); } - (void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOrientation { [super didRotateFromInterfaceOrientation:fromInterfaceOrientation]; NSLog(@"%@ (%p) - %@", NSStringFromClass([self class]), self, NSStringFromSelector(_cmd)); } @end
There is nothing special about our view controller itself, except that we've overridden the various callbacks so that we can log them whenever our ViewController instance becomes a child to our root controller and an appearance or rotation event occurs.
In all of the previous code, _cmd
refers to the selector corresponding to the method that our execution is inside. NSStringFromSelector()
converts it to a string. This is a quick and easy way to get the name of the current method without having to manually type it out.
Let's throw a navigation controller and a tab controller into the mix. This time we'll use Storyboards.
Create a new file, and under iOS > User Interface, choose storyboard. Set the device family to iPhone, and name it NavStoryBoard.
From the objects library, drag and drop a Navigation Controller object into the canvas. Drag and drop a bar button item into the left side of the navigation bar in the table view controller designated as "Root View Controller". This contains the table view in the canvas. Give it any name. I've named it "Left". Its purpose is to verify the code we wrote to make the menu bar's hide/reveal button take its place as the leftmost button on the navigation bar, pushing any already present buttons to the right. Finally, drag a View Controller instance and place it to the right of the controller titled "Root View Controller" in the canvas.
Click where it says "Table View" in the center of the second controller, and in the attributes inspector change the content from "Dynamic Prototype" to "Static Cells".
This will cause three static table view cells to appear in the interface builder. Delete all but one of these table view cells, and while holding down Control, click and drag from the remaining cell to the view controller on the far right and release. Select "push" under Selection Segue. All this does is cause a segue to the right view controller when you tap on the lone cell from the table view. If you want, you can drop a UILabel onto the table cell to give it some text. Your storyboard should look similar to the photo below.
Finally, let's add a tab bar controller. Just as you did previously, create a storyboard file and call it TabStoryBoard. Drag and drop a tab bar controller item from the object library into the canvas. It comes preconfigured with two tabs and, if you like, you can change the background color of the two tabbed view controllers by clicking on the view corresponding to either view controller and changing the "background" option in the Attributes Inspector. This way, you can verify that view controller selection through the tab is working correctly.
4. Configuring the App Delegate
Now it's time to set everything up in the AppDelegate.
Replace the code in AppDelegate.m with the following code.
#import "AppDelegate.h" #import "RootController.h" #import "ViewController.h" @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; UIStoryboard *tabStoryBoard = [UIStoryboard storyboardWithName:@"TabStoryboard" bundle:nil]; UIStoryboard *navStoryBoard = [UIStoryboard storyboardWithName:@"NavStoryboard" bundle:nil]; UINavigationController *navController = [navStoryBoard instantiateViewControllerWithIdentifier:@"Nav Controller"]; UITabBarController *tabController = [tabStoryBoard instantiateViewControllerWithIdentifier:@"Tab Controller"]; ViewController *redVC, *greenVC; redVC = [[ViewController alloc] init]; greenVC = [[ViewController alloc] init]; redVC.view.backgroundColor = [UIColor redColor]; greenVC.view.backgroundColor = [UIColor greenColor]; RootController *menuController = [[RootController alloc] initWithViewControllers:@[tabController, redVC, greenVC, navController] andMenuTitles:@[@"Tab", @"Red", @"Green", @"Nav"]]; self.window.rootViewController = menuController; self.window.backgroundColor = [UIColor whiteColor]; [self.window makeKeyAndVisible]; return YES; }
All we did was create instances of ViewController
and instantiate the navigation and tab controller from the two storyboards. We passed them in an array to our RootController
's instance. That's the container controller we implemented at the start. We did this along with an array of strings to name the view controllers in the menu. Now we'll simply designate our initialized Root Controller instance as the window's rootViewController
property.
Build and run the app. You've just implemented container containment! Tap on the various table cells in the menu to replace the visible slide with the new one sliding in from the right. Notice how, for the navigation controller instance (named "NavC" in the menu), the "Left" button has shifted one place to the right and the menu bar button has taken up the leftmost position. You can change the orientation to landscape and verify that everything looks proper.
Conclusion
In this introductory tutorial, we looked at how view controller containment is implemented in iOS 6. We developed a simple version of a custom app interface that has gained much popularity and is often seen in highly-used apps such as Facebook and Path. Our implementation was as simple as possible, so we were able to dissect it easily and get the basics right. There are many sophisticated open-source implementations of this type of controller that you can download and study. A quick Google search turns up JASidePAnels
and SWRevealViewController
, among others.
Here are some ideas for you to work on.
- Make the implementation more flexible and the API more customizable.
- Make the interface prettier. You can customize the table view cell appearance, or let your view controller's view throw a shadow on the menu to lend the interface some depth.
- Make the interface adapt better to the orientation. Remember that your child view controllers will be receiving rotation notifications, so that's where you start!
- Implement gesture recognition so that you can drag a view controller left and right across the screen as an alternate to clicking on the menu button.
- Design and develop a completely new and novel app flow and realize the UI in code. Chances are you'll need to make use of view controller containment!
One thing I'd like to mention here is that in Xcode 4.5 onwards, there is a new interface builder object called "Container View" which can display the contents of a view controller, and thus be used to implement containment directly in your storyboard! Happy coding!
Comments