Jul 232013
 

I’m working on an exercise timer in my spare time. The design called for different UI for portrait and landscape and both rubber banding and auto-layout just wasn’t going to work. Objects moved when I rotated the device.

topologically

Notice how the green box is below the magenta box in the portrait version, yet above it and to the left of the orange box in the landscape version.

So I tried using multiple xib files, one for portrait and one for landscape. I’ve done this before and things were fine but that was awhile ago. With iOS6 things were not fine.

ios6

Stackoverflow.com is a great resource for tricky situations just like this, so I asked there about this. And if you go there you’ll see that my question was not answered. I had a friend help me out, he used 2 views in one xib file with IBOutlets for portrait and landscape view and he toggled between them the device rotated. Perfect, right? Well, no, when you have 2 views in a XIB you can’t hook up your IBOutlets to both places. I had it working visually but my controls only worked in one orientation.

I eventually came up with the idea of using a orientation master view controller that loaded container view controllers when the device rotated. That worked fine. Lets look at the code:

-(void)willAnimateRotationToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation duration:(NSTimeInterval)duration
{
    if (_timerViewController) {
        [_timerViewController.view removeFromSuperview];
        [_timerViewController willMoveToParentViewController:nil];
        [_timerViewController removeFromParentViewController];
        self.timerViewController = nil;
    }
    
    self.timerViewController = [[XTMViewController alloc] initWithNibName:
          [self xibNameForDeviceAndRotation:interfaceOrientation withClass:[XTMViewController class]]
                                             bundle:nil];
    
    // use bounds not frame since frame doesn't take the status bar into account
    _timerViewController.view.frame = _timerViewController.view.bounds = self.view.bounds;
    
    [self addChildViewController:_timerViewController];
    [_timerViewController didMoveToParentViewController:self];
    [self.view addSubview: _timerViewController.view];
}

The addChildViewController and didMoveToParentViewController should be familiar if you read my previous blog entry on Container View Controllers. There are two things to notice above those calls though. I’ll deal with the second one first, I set the child view controller’s frame and bounds from the parents bounds, not frame. This is to take account of the status bar.

And notice the call to xibNameForDeviceAndRotation to load the view controller from its xib file. Lets look at that code:

- (NSString *) xibNameForDeviceAndRotation:(UIInterfaceOrientation)toInterfaceOrientation withClass:(Class) class;
{
    NSString *xibName ;
    NSString *deviceName ;

    if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
        deviceName = @"iPad";
    } else {
        deviceName = @"iPhone";
    }

    if( UIInterfaceOrientationIsLandscape(toInterfaceOrientation) )  {
        xibName = [NSString stringWithFormat:@"%@-Landscape", NSStringFromClass(class)];
        if([[NSBundle mainBundle] pathForResource:xibName ofType:@"nib"] != nil) {
            return xibName;
        } else {
            xibName = [NSString stringWithFormat:@"%@_%@-Landscape", NSStringFromClass(class), deviceName];
            if([[NSBundle mainBundle] pathForResource:xibName ofType:@"nib"] != nil) {
                return xibName;
            } else {
                NSAssert(FALSE, @"Missing xib");
                return nil;
            }
        }
    } else {
        xibName = [NSString stringWithFormat:@"%@", NSStringFromClass(class)];
        if([[NSBundle mainBundle] pathForResource:xibName ofType:@"nib"] != nil) {
            return xibName;
        } else {
            xibName = [NSString stringWithFormat:@"%@_%@", NSStringFromClass(class), deviceName];
            if([[NSBundle mainBundle] pathForResource:xibName ofType:@"nib"] != nil) {
                return xibName;
            } else {
                NSAssert(FALSE, @"Missing xib");
                return nil;
            }
        }
    }
    return nil;
}

There’s a lot going on here. Let’s go over it. I first determine if you are on an iPhone or an iPad. The xib files will have iPhone or iPad in their names. Next we check to see if we are in landscape mode. If we are, we build a test string from the class name, using class reflection via NSStringFromClass. Next, we use pathForResource to check to see if the xib exists in our bundle. If it does, we return the xib name. If it doesn’t, we try again also putting the device name into the xib name. Return it if it exists, assert a failure if it doesn’t. Portrait is similar except by convention we don’t put “-Portrait” into the xib name.

This code is useful enough and generic enough that I’ll put it in my EnkiUtils open source project.

Since this is iOS6 we need to put in the iOS6 rotation boilerplate code:

- (BOOL) shouldAutorotate
{
    return YES;
}

- (NSUInteger)supportedInterfaceOrientations {
    return UIInterfaceOrientationMaskAll;
}

Curiously we also need to manually call willAnimateRotationToInterfaceOrientation on iPads. iPhones get a willAnimateRotationToInterfaceOrientation automatically but iPads do not.

- (void) viewDidAppear:(BOOL)animated
{
    // iPad's don't send a willAnimate on launch...
    if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
        [self willAnimateRotationToInterfaceOrientation:[[UIApplication sharedApplication] statusBarOrientation] duration:0];
    }
}

So, are we finished? Embarrassingly no. You see, when I coded the XTMViewController class I broke the Model-View-Controller design pattern! This is easy to do, Apple already helps us by putting the View and the Controller into the same class. And it is so easy to carelessly mix in Model data in the VC’s .h file. And I had done exactly that. When I run the above code it work brilliantly, I could rotate it all day and the UI was correct in both orientations. But what do you think happened when I rotated the device while my exercise timers were running? Yup, they were all deleted and the UI reset to the initial state. This was not at all what I wanted!

I made a XTMUser class to hold all the timing data, I put all the NSTimers into the  XTMOrientationMasterViewController class and then I made a protocol so the XTMOrientationMasterViewController could respond to UI taps in the XTMViewController class.

Then I was done.

landscape portrait