Converting to a Universal App (Part III)

This is the last in a series of posts on my notes on migrating from an existing iPhone App to a universal app that will run on iPad and iPhone devices. The first post covered the basic process of converting your Xcode project to run under OS 3.2. The second post covered some general refactoring advice which you should consider before getting too far into iPad specific changes. Finally are my notes on adopting the new UI elements introduced in OS 3.2 for the iPad.

Flattening your view

It is fairly easy to get something up and running on the iPad that looks better than the pixel doubling horror that pre OS 3.2 apps suffer by default. However to really make use of the larger screen size of the iPad and create a polished interface takes a bit more work. A typical iPhone application makes use of a navigation and/or tab bar to transition between single views that take up the whole screen. This basic design no longer holds on the iPad and has a number of implications for your controller code.

The basic strategy when converting to a universal app is to try to reduce the number of levels in your view hierarchy. On the much larger device sliding full screen views in and out as you drill down a deep view hierarchy is best avoided. The much larger screen size means you can display more information, something that Apple has made easier by introducing new UI elements such as the Split View Controller and the Popover.

The Split View Controller

The Split View Controller together with its close companion the popover are the two most obvious new elements introduced in iPhone OS 3.2. Except for simple utility style apps it is likely to be first controller that is loaded in any iPad app.

SplitViewController Landscape mode

The key thing to understand about the Split View Controller is that it is really just a container for two view controllers. In fact it only defines two new properties, the id of a delegate and an array to contain the two view controllers:

@property (nonatomic,copy)    NSArray *viewControllers;
@property (nonatomic, assign) id <UISplitViewControllerDelegate> delegate;

The first view controller provides the master view and appears in the left pane in landscape mode. The second view controller provides the detail view and appears in the right-hand pane in landscape mode. When in portrait mode the master view is hidden and the detail view fills the screen. By registering a delegate with the Split ViewController you can arrange to show a popover view containing the master view when in portrait mode.

SplitViewController Portrait mode with Popover

Remember that iPad apps should support all orientations so you need to consider how the UI will work in both portrait and landscape modes. If you are converting an existing iPhone app that uses a navigation bar the obvious choice is to make your existing root view controller the master view and the next view controller in your navigation hierarchy the detail view. Note that the Split View Controller will only handle two view controllers, you cannot and should not put more (or less) than two controllers into its NSArray.

To get started add an instance variable to your application delegate to hold a reference to the UISplitViewController and add an IBOutlet property as follows:

@interface FlashcardAppDelegate : NSObject <UIApplicationDelegate> {
  UISplitViewController *splitViewController;
}

@property (nonatomic, retain) IBOutlet UISplitViewController *splitViewController;

Modify your MainWindow-iPad.xib and delete your existing view controllers from the NIB and drag a UISplitViewController from the library. You then need to wire up the outlet from your application delegate and set the master and detail view controllers.

Make sure you change the classes of the view controllers (in the above example the RootViewController and DetailViewController should be set to your controller classes). Also set the delegate of the UISplitViewController to be the DetailViewController. To make the UISplitViewController load when your app is running on the iPad but not when it is running on the iPhone you need to add some conditional code to your app delegate:

if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
  [window addSubview:splitViewController.view];
} else {
  [window addSubview:navBarController.view];  
}

The UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad test can be used anywhere you need to determine if you are running on an iPad.

Master - Detail View Controller Communications

Having both the master and detail view controller displayed at the same time requires some changes to the way those view controllers interact. On the iPhone the detail view controller is generally allocated, initialised and pushed onto the navigation stack in response to a table row being selected in the root view controller. This means that the information that the detailed view controller has to display can be set at that point.

When using the UISplitViewController both controllers are loaded (in landscape mode) when the app launches and will stay displayed as the user selects different items in the master view controller. This has two immediate implications:

  • the detail view controller has to be able to handle the initial load before the user has selected a row in the master view controller. It can decide to default to an item or display an empty screen (perhaps with some text asking the user to select something).
  • the master view controller needs to be able to communicate with the detail view controller when the user selects items.

The simplest way for the master view controller to communicate with the detail view is by maintaining a reference to the detail view controller from the master. Some conditional code then takes care of populating the detail view when a row is selected:

- (void)tableView:(UITableView *)tableView
        didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
  if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
    // Set the details here
    [self.detailViewController updateDetail];
  } else {
    DetailViewController *viewController = [[DetailViewController alloc]
                          initWithNibName:@"DetailView" bundle:nil];
    // Set the details here
    [[self navigationController] pushViewController:viewController
                                           animated:YES];
    [viewController release];
  }
}

In the above example the updateDetail method is defined in the detailViewController and will update the detail view when running on the iPad. This approach works well for simple applications but it does hard code the master-detail relationship into the two controllers. You also need to make the connection from the master controller in the NIB file which is not always convenient. As an alternative you can also consider using notifications to communicate changes to the detail view.

SplitViewController Delegate and Popovers

To deal with the UISplitViewController in portrait mode you should ensure your detailViewController implements the UISplitViewControllerDelegate protocol and add instance variables for a popover controller and toolbar. The toolbar can either be added to the detailView NIB file or created in code when the detailViewController initialised. One trick you can use here is to add the code to allocate the toolbar in the awakeFromNib method as this is only called when the controller is loaded from a NIB file. The iPhone version allocates and initialised the detailViewController in code so the awakeFromNib method will never be called.

@interface DetailViewController : UIViewController <UISplitViewControllerDelegate> { 
    UIPopoverController *popoverController;
    UIToolbar *toolbar;
    ...
}

@property (nonatomic, retain) IBOutlet UIToolbar *toolbar;
...
@end

Two methods defined by the UISplitViewControllerDelegate inform the detailViewController when the master view controller appears and disappears as the user rotates the device between landscape and portrait modes.

// Called when the master view controller is about to be hidden
- (void)splitViewController: (UISplitViewController*)svc
        willHideViewController:(UIViewController *)aViewController 
        withBarButtonItem:(UIBarButtonItem*)barButtonItem
        forPopoverController: (UIPopoverController*)pc {
  barButtonItem.title = @"Master View";
  NSMutableArray *items = [[toolbar items] mutableCopy];
  [items insertObject:barButtonItem atIndex:0];
  [toolbar setItems:items animated:YES];
  [items release];
  self.popoverController = pc;
}

// Called when the master view controller is about to appear
- (void)splitViewController: (UISplitViewController*)svc 
        willShowViewController:(UIViewController *)aViewController 
        invalidatingBarButtonItem:(UIBarButtonItem *)barButtonItem {
  NSMutableArray *items = [[toolbar items] mutableCopy];
  [items removeObjectAtIndex:0];
  [toolbar setItems:items animated:YES];
  [items release];
  self.popoverController = nil;
}

As the master view controller disappears a button is inserted into the toolbar and the popover controller is passed the master view controller by the split view controller delegate protocol (using the pc parameter). When the master view controller is about to appear the button is removed and the popover controller is released.

One additional thing you should do is remove the popover whenever the user selects an item from it. This is easier enough to do in the same updateDetail method we were calling from the master view controller earlier.

if (popoverController != nil) {
  [popoverController dismissPopoverAnimated:YES];
}

Tab Bar Controllers

One big difference between the iPad and iPhone is that Apple expects that you will use the tab bar controller much less on the iPad. In fact they provide no way for you to use a tab bar controller in conjunction with a split view controller. It is possible to have a tab bar inside one of the view controllers but what you cannot do is have a tab bar controller at the root of your view hierarchy and it have switch between embedded split view controllers. This design decision by Apple can cause some headaches if you are migrating an iPhone app that makes use of a tab bar controller. There are a number of options you can try to redesign the UI for the iPad:

  • Use a split view controller but introduce a new root view controller that allows you to switch between what were previously the items in your tab bar. This is perhaps the simplest approach but in my opinion is an ugly solution. You want to flatten your views on the iPad and this approach introduces an extra level.
  • Move options to a toolbar. This approach works well for tab bar items such as settings. The much greater screen size means there is a lot of space to have extra icons in the toolbar that are always visibile to the user.
  • If you really do have tab bar items that represent different contexts in your app consider adding a segmented control to the toolbar and using that to switch between different master-detail views.

Separating the iPad and iPhone delegates

One final comment as you work your way through your view controllers. You may hit a point where the amount of conditional code starts to make your controllers a mess. You may also find that you really do want seperate views for the iPhone and iPad to take advantage of the greater screen size on the iPad. In that case you may be best to split your application delegates completely and use device specific controllers in places where the interface is no longer shared. The Xcode universal app template provides a good example of how to do this by hooking up different app delegate classes to the iPhone and iPad MainWindow NIB files and then separating code into iPhone, iPad or shared trees: