Switching Views with a UISegmentedControl - Revisited

posted by crafterm, 27 June 2010

Recently I investigated switching between multiple different views using a UISegmentedControl, similar to iCal or the AppStore application.

Background

The best approach I could find to work at the time was to use a managing view controller, that enclosed an array of sub view controllers. The UISegmentedControl would then switch between these sub views by adding/removing the selected segment’s view as a subview of the managing controller’s view.

While this worked and satisfied one of my main requirements of keeping the logic between view controllers separate, there were a few things that frustrated me about the approach:

  • The enclosing view controller that managed the sub views was in effect a ‘container’ view controller, and I distinctly remembered Evan Doll’s WWDC 2009 presentation where cautioned against building any style of container view controllers.

  • To push onto the navigation stack from within a sub view, each sub view needed to have a reference back to the managing container view controller, either via a property and/or custom constructor, as the sub views weren’t created within the navigation hierarchy and hence had a nil navigationController property.

  • Since the managing view controller enclosed a series of sub view controllers, some in the view hierarchy and some not, view life cycle messages needed to be forwarded to the sub view controllers to be good UIKit citizens.

While attending WWDC 2010 just a few weeks ago, I managed to talk to several UIKit engineers and together we managed to find a much better approach to solving this UI paradigm without requiring a container view controller.

New Shiny

The new approach is to utilize a UINavigationController rather than a managing view controller. However, rather than use the more common navigation controller methods pushViewController:animated: and popViewControllerAnimated:, we will manipulate the navigation view hierarchy directly by modifying the viewControllers property using the setViewControllers:animated: method.

The technique essentially works as follows. Any index change in the designated UISegmentedControl calls upon a method in a custom NSObject descendant controller object of ours. This controller accesses the selected view controller appropriate for the selected segment and installs it into the navigation controller stack directly using the setViewControllers:animated: method.

Finally, since the navigation view hierarchy has been modified directly, we then re-install the segmented control as the title view on the incoming view controller so that further segment changes can be made.

I’ve reimplemented the previous example application I built using the managing view controller with this new pattern, let’s step through it to demonstrate how it all works.

Since we’ll be using standard UINavigationController and UISegmentedControl objects in this application, I’ll skip straight to our custom controller object that accepts a message indicating a change in selected segment index, and does the navigation controller magic.

Interface

@interface SegmentsController : NSObject {
    NSArray                * viewControllers;
    UINavigationController * navigationController;
}

@property (nonatomic, retain, readonly) NSArray                * viewControllers;
@property (nonatomic, retain, readonly) UINavigationController * navigationController;

- (id)initWithNavigationController:(UINavigationController *)aNavigationController
                   viewControllers:(NSArray *)viewControllers;

- (void)indexDidChangeForSegmentedControl:(UISegmentedControl *)aSegmentedControl;

@end

Here we define the SegmentsController interface to be an NSObject descendant, with storage for the view controllers appropriate for each segment, and a reference to our navigation controller.

A custom constructor accepts the view and navigation controller, and the indexDidChangeForSegmentedControl: is our method that can be invoked when a given segmented control index changes.

Implementation

@interface SegmentsController ()
@property (nonatomic, retain, readwrite) NSArray                * viewControllers;
@property (nonatomic, retain, readwrite) UINavigationController * navigationController;
@end

@implementation SegmentsController

@synthesize viewControllers, navigationController;

- (id)initWithNavigationController:(UINavigationController *)aNavigationController
                   viewControllers:(NSArray *)theViewControllers {
    if (self = [super init]) {
        self.navigationController = aNavigationController;
        self.viewControllers = theViewControllers;
    }
    return self;
}

- (void)indexDidChangeForSegmentedControl:(UISegmentedControl *)aSegmentedControl {
    NSUInteger index = aSegmentedControl.selectedSegmentIndex;
    UIViewController * incomingViewController = [self.viewControllers objectAtIndex:index];

    NSArray * theViewControllers = [NSArray arrayWithObject:incomingViewController];
    [self.navigationController setViewControllers:theViewControllers animated:NO];

    incomingViewController.navigationItem.titleView = aSegmentedControl;
}

- (void)dealloc {
    self.viewControllers = nil;
    self.navigationController = nil;
    [super dealloc];
}

@end

In the anonymous category we redefine our properties read/write so we can mutate them from within the implementation only, and define our constructor to store references to our given view and navigation controllers.

The meat of the work is done next. Our segmented control will be appropriately configured to call upon indexDidChangeForSegmentedControl: when it’s segment index changes. When this occurs, we retrieve the new index from the segmented control, and the relevant view controller to install (a more complex example could instantiate/cache view controllers to conserve memory), and assign it to the navigation controller via the setViewControllers:animated: message.

Once installed, we then apply the segmented control to the title view of the view controller we just installed into the navigation controller, and are done.

Finally, we implement appropriate memory management methods to de-allocate resources when being released.

Application Delegate Implementation

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    NSArray * viewControllers = [self segmentViewControllers];

    UINavigationController * navigationController = [[[UINavigationController alloc] init] autorelease];
    self.segmentsController = [[SegmentsController alloc] initWithNavigationController:navigationController viewControllers:viewControllers];

    self.segmentedControl = [[UISegmentedControl alloc] initWithItems:[viewControllers arrayByPerformingSelector:@selector(title)]];
    self.segmentedControl.segmentedControlStyle = UISegmentedControlStyleBar;

    [self.segmentedControl addTarget:self.segmentsController
                              action:@selector(indexDidChangeForSegmentedControl:)
                    forControlEvents:UIControlEventValueChanged];

    [self firstUserExperience];

    [window addSubview:navigationController.view];
    [window makeKeyAndVisible];

    return YES;
}

In this example, I’ve instantiated and configured the segmented control, navigation and segment controllers from within the application delegate, but this could equally be done at a lower level depending on your application.

In particular, the segmented control has its target/action pair set up to point to our indexDidChangeForSegmentedControl: method described above.

The segmentViewControllers methods returns the view controllers relating to each segment (the title property of each view controller is used as the segment’s title via the NSArray arrayByPerformingSelector: extension), and firstUserExperience kicks everything off by selecting and installing the first segment.

- (NSArray *)segmentViewControllers {
    UIViewController * italy     = [[ItalyViewController alloc] initWithNibName:@"ItalyViewController" bundle:nil];
    UIViewController * australia = [[AustraliaViewController alloc] initWithStyle:UITableViewStyleGrouped];

    NSArray * viewControllers = [NSArray arrayWithObjects:italy, australia, nil];
    [australia release]; [italy release];

    return viewControllers;
}

- (void)firstUserExperience {
    self.segmentedControl.selectedSegmentIndex = 0;
    [self.segmentsController indexDidChangeForSegmentedControl:self.segmentedControl];
}

Summary

Using a UINavigationController based approach has several distinct advantages that I quite like – it doesn’t require a container controller, and hence no custom code for handling memory, rotation, or view life cycle events.

Code-wise it’s much smaller than the previous implementation, and will be a lot easier to maintain. Segment view controllers can push directly onto the navigation controller stack since they’re set within the navigation hierarchy, and no special management of parent view controllers is required.

The full XCode project of the example above is available if you’d like to examine it further. Enjoy.