Traditionally in iOS a view controller corresponded to one "screen" in the application. To help developers manage multiple view controllers and navigate between them Apple provided a few container view controllers. The most prominent examples of these were the navigation controller and tab bar controller.
However (since iOS 5) you can also define your own custom container view controllers. This allows great flexibility in the design of your user interface. It also encourages you to break up what previously might have been very large view controllers, managing multiple aspects of an application, into smaller view controllers with a coherent logical responsibility in the app.
Aside from encapsulating a logical portion of the application and
managing the related events between the models and views, a view
controller is also responsible for responding to events it receives from
the system. These include life cycle events like viewDidLoad
,
viewWillAppear
, viewDidDisappear
, etc. They also include changes in
the associated view's size (say due to a change in the orientation of
the device) such as
viewWillTransitionToSize
. When
implementing a custom container view controller we must be careful to
forward these events to any child view controllers.
To demonstrate how to implement a custom container view controller, we'll implement a simple drop down menu that allows the user to switch to any view controller in the list.
We start out by creating a new subclass of UIViewController
called
MenuViewController
. We do this by selecting File -> New -> File...
-> Source -> Cocoa Touch Class
. Here we also create the associated
nib file by marking the Also create
XIB file
in the wizard.
Now we can open up our MenuViewController.xib
and lay out our views.
We add a navigation bar, and "Menu" bar button item. We add a blank
view (colored pink below) as a container for the views of our child view
controllers. Finally we add the table view that will display our menu.
Each cell in this table will be an item the user can select in the menu.
Next we set the custom class of the file's
owner object to
MenuViewController
and create these outlets from the file's owner
using the assistant editor (tuxedo view). We also create an @IBAction
to handle the event of the menu button being tapped.
class MenuViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var activeViewContainer: UIView!
@IBOutlet weak var navItem: UINavigationItem!
@IBOutlet weak var tableViewHeightConstraint: NSLayoutConstraint!
...
@IBAction func didTapMenuButton(sender: AnyObject) {
}
...
}
We'll have to be able to maintain a list of view controllers that are
available in the menu. We'll keep an private array
viewControllerArray
and will allow consumers to set this array via the
viewControllers
property. To keep the implementation simple we won't
support dynamically modifying the viewControllers
array by inserting
into the array. Calling the getter will return an immutable copy.
Finally we reset the activeViewController
if viewControllers
is
set to a new array that does not contain the current
activeViewController
.
class MenuViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
...
private var viewControllerArray: [UIViewController] = []
var viewControllers: [UIViewController] {
get { // getter returns read only copy
let immutableCopy = viewControllerArray
return immutableCopy
}
set {
viewControllerArray = newValue
// set the active view controller to the first one in the new array if the current one is not in there
if activeViewController == nil || viewControllerArray.index(of:activeViewController!) == nil {
activeViewController = viewControllerArray.first
}
}
}
...
}
In order to keep track of which view controller is currently being
displayed, we'll maintain a activeViewController
variable. When this
variable is set we'll remove the previous view controller's view and
swap in the new active view controller's view and set the navigation bar
title to the the new active view controller's title.
In order for the view controller's life cycle events and other system
events to propagate properly we have to call
addChildViewController
before adding a child
view controller's view as a subview of our view. To notify the child
view controller that we finished adding its view to the view hierarchy
we have to call didMoveToParentViewController
afterwards.
Likewise when removing a child view controller's view from the
hierarchy, we first have to notify it we will do this by calling
willMoveToParentViewController
with nil
as the
new parent view controller. After we are done removing the child view
we have to also remove its view controller from our set of child view
controllers by calling its
removeFromParentViewController
method.
More information on child view controller management and implementing custom view controlers can be found in guide from Apple.
class MenuViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
...
private var activeViewController: UIViewController? {
didSet {
removeInactiveViewController(oldValue)
updateActiveViewController()
}
}
private func removeInactiveViewController(inactiveViewController: UIViewController?) {
if isViewLoaded() {
if let inActiveVC = inactiveViewController {
inActiveVC.willMoveToParentViewController(nil)
inActiveVC.view.removeFromSuperview()
inActiveVC.removeFromParentViewController()
}
}
}
private func updateActiveViewController() {
if isViewLoaded() {
if let activeVC = activeViewController {
addChildViewController(activeVC)
activeVC.view.frame = activeViewContainer.bounds
activeViewContainer.addSubview(activeVC.view)
navItem.title = activeVC.title
activeVC.didMoveToParentViewController(self)
}
}
}
...
}
We use the height constraint on tableView
to show and hide our menu.
We hide it initially in viewDidLoad
and then animate it open/close
every time the menu button is tapped.
class MenuViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
...
// MARK: view did load
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
tableView.delegate = self
tableView.registerClass(UITableViewCell.self, forCellReuseIdentifier: "TableViewCell")
tableView.rowHeight = 50
// menu is hidden to start
self.tableViewHeightConstraint.constant = 0
updateActiveViewController()
}
// MARK: menu button handler
@IBAction func didTapMenuButton(sender: AnyObject) {
if (tableViewHeightConstraint.constant == 0) {
showMenu()
} else {
hideMenu()
}
}
private func hideMenu() {
UIView .animateWithDuration(0.3, animations: { () -> Void in
self.tableViewHeightConstraint.constant = 0
self.tableView.layoutIfNeeded()
});
}
private func showMenu() {
UIView .animateWithDuration(0.3, animations: { () -> Void in
let totalHeight = self.tableView.rowHeight * CGFloat(self.tableView.numberOfRows(inSection: 0))
self.tableViewHeightConstraint.constant = totalHeight
self.tableView.layoutIfNeeded()
});
}
...
}
Finally we use our table view data source and delegate methods to
populate our menu table with one cell for each view controller in the
viewControllerArray
. When the user selects a cell we set the
corresponding view controller to be the active view controller and hide
the menu. Notice that the observer on activeViewController
above will do the
swapping in/out of the child view controllers for us.
class MenuViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
...
// MARK: view controller delegates
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewControllerArray.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("TableViewCell", forIndexPath: indexPath) as UITableViewCell
cell.textLabel?.text = viewControllerArray[indexPath.row].title
return cell
}
func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
tableView.deselectRowAtIndexPath(indexPath, animated: true)
activeViewController = viewControllerArray[indexPath.row]
hideMenu()
}
}
If you are familiar with built-in container view controllers such as the navigation controller and tab bar controller, you'll know that in order to use them in a storyboard you have to create a relationship segue (e.g. to the navigation controller's root view controller). Unfortunately there is no way to create a custom relationship segue for your custom container view controllers.
This means that is difficult to set custom container view controllers as the
root view of your storyboard. We can try something like implementing
initWithCoder
to load our nib and then setting the custom class of
a view controller in the storyboard (see the custom views
guide for more information on how nibs are loaded).
class MenuViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
...
required init(coder aDecoder: NSCoder) {
super.init(nibName: "MenuViewController", bundle: nil)
}
}
However because there is no way to create relationship segues, the only
place to initialize the other view controllers that will be in our
viewControllers
array is in viewDidLoad
. However, if we want to
make our MenuViewController
reusable, this clearly breaks
encapsulation.
One way we can solve this problem is to manually set up the root view controller in the AppDelegate (the way it was done before storyboards existed). This can be done as follows.
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
let vc1 = UIViewController()
let vc2 = UIViewController()
let vc3 = UIViewController()
vc1.title = "First"
vc2.title = "Second"
vc3.title = "Third"
vc1.view.backgroundColor = UIColor.blue
vc2.view.backgroundColor = UIColor.green
vc3.view.backgroundColor = UIColor.yellow
let menuViewController = MenuViewController(nibName: "MenuViewController", bundle: nil)
// the window object is already created for us since this is a storyboard app
// we would have to initialize this manually in non-storyboard apps
window?.rootViewController = menuViewController
menuViewController.viewControllers = [vc1, vc2, vc3]
return true
}
...
}
Notice that we instantiated our MenuViewController
by calling
initWithNibName
. We can also load in other view
controllers (say the items in our menu) this way. Since this was a demo
app we just instantiated the child view controllers as instances of
UIViewController
. If we had needed to instantiate a view
controller from the storyboard we could have done something like this
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let viewController = storyboard.instantiateViewControllerWithIdentifier("IdentifierThatWasSetInStoryboard") as MyViewControllerClass