Aleksandar • Vacić

iOS bits and pieces

Allow landscape, only on iPhone 6 or 6 Plus

I sort of already solved this - how to allow certain orientation per view controller, late last year while (surprise!) working on the very same app I’m updating now: Banca currency converter.

However, this is a bit different. iOS 8 brings size classes, deprecates old orientation callbacks and in general tries to move away from dealing with fixed numbers.

Oki, what’s the deal here; iPhone 6 Plus has a system-wide support for operating in landscape mode, including home screen arrangement with the dock on the side. Thus it’s more than expected that our apps should work in landscape on iPhone 6 Plus. Gone are the days when you could get away with just Portrait, since that is the natural way to hold the iPhone (for all but video and games).

It’s not as easy as this:

as with that you enable all orientations for all devices. In my case, solution needed to include the iPad as well, where all orientations are enabled by default. Thus in AppDelegate I added this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (NSUInteger)application:(UIApplication *)application
   supportedInterfaceOrientationsForWindow:(UIWindow *)window {
  
if (window.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomUnspecified)
  return UIInterfaceOrientationMaskAll;

if (window.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad)
  return UIInterfaceOrientationMaskAll;

if (window.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular)
  return UIInterfaceOrientationMaskAllButUpsideDown;

...

return UIInterfaceOrientationMaskPortrait;
}

If this method is present, UIKit will ignore the project settings from Xcode. It checks, in the following order:

  1. If window traits are not known, allow all.

  2. If iPad, allow all.

  3. If it’s a device with Regular horizontal size class – this would be 6 Plus – then allow all.

Otherwise, fix it at portrait. Trouble here is that, at least in Simulator, my code always exists at the end, going through all the ifs. Even on 6 Plus, when app is started with Simulator in landscape, I get values like any other iPhone:

1
2
3
4
5
6
(lldb) po window.traitCollection
<UITraitCollection: 0x7fa74af7ef40;
_UITraitNameUserInterfaceIdiom = Phone, _UITraitNameDisplayScale = 3.000000,
_UITraitNameHorizontalSizeClass = Compact,
_UITraitNameVerticalSizeClass = Regular,
_UITraitNameTouchLevel = 0, _UITraitNameInteractionModel = 1>

This is rather unfortunate, as in the same delegate I load my ColumnViewController which should already have horizontalSizeClass == UIUserInterfaceSizeClassRegular (if started in landscape) so it can properly layout itself.

Thus, the app always starts in portrait, gets shown in portrait for a split second and then immediatelly flashes and goes to landscape. If you breakpoint in the method above, you can possibly even prevent the Simulator from automatically switching the orientation (joys of debugging).

If, after app is started, I rotate slightly back and forth to trigger orientation change, and break point (in ColumnVC which is my root controller):

1
2
3
4
5
- (void)willTransitionToTraitCollection:(UITraitCollection *)newCollection
   withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {

   ...
}

then newCollection will have expected size classes:

1
2
3
4
5
<UITraitCollection: 0x7fa74ae099a0;
_UITraitNameUserInterfaceIdiom = Phone, _UITraitNameDisplayScale = 3.000000,
_UITraitNameHorizontalSizeClass = Regular,
_UITraitNameVerticalSizeClass = Compact,
_UITraitNameTouchLevel = 0, _UITraitNameInteractionModel = 1>

Thus now it’s possible to adjust constraints and whatever for subviews. A word of advice here - if you, for whatever reason, need to use this new iOS 8 method instead:

1
2
3
4
- (void)viewWillTransitionToSize:(CGSize)size
   withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator {
   ...
}

it’s easy to make a mistake and try to use self.traitCollection to check the size classes - they are still the old ones, from portrait:

1
2
3
4
5
<UITraitCollection: 0x7fa74d228240;
_UITraitNameUserInterfaceIdiom = Phone, _UITraitNameDisplayScale = 3.000000,
_UITraitNameHorizontalSizeClass = Compact,
_UITraitNameVerticalSizeClass = Regular,
_UITraitNameTouchLevel = 0, _UITraitNameInteractionModel = 1>

Key word is will in the method names, I learned that the hard way.
I can’t help but think I’m missing something here, but there should be a way for my main view controller to know what size class will eventually be, once app is started.

Thus, if you don’t mind the initial flashing, this is all it takes. If you do mind it and/or want to include iPhone 6 in this landscape business, then there’s a way to do it.

What about iPhone 6?

iPhone 6 is, looking at size classes, treated the same way as previous iPhones. However, given its size and especially for the app like Banca which has a lot of white space, it makes sense to allow landscape use. So, how to enforce it?

Solution I found is based on technique shown in Session 216 from WWDC 2014 – Building Adaptive Apps with UIKit. See the discussion starting from 21:15. The key is to add another dummy VC at the top which will update the traitsCollection of your actual, main controller.

The sample code for this session is available online so you can see the full contents of the traits overrider - it’s “AdaptivePhotos: An Adaptive Application” on this page – but these are the key lines:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
- (void)viewWillTransitionToSize:(CGSize)size
   withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator {

if (size.width > 320.0) {
  // If we are large enough, force a regular size class
  self.forcedTraitCollection = [UITraitCollection traitCollectionWithHorizontalSizeClass:UIUserInterfaceSizeClassRegular];
} else {
  // Otherwise, don't override any traits
  self.forcedTraitCollection = nil;
}

[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
}

...

- (void)setController:(UINavigationController *)controller {
if (_controller != controller) {
  ...
  
  if (controller) {
      [self addChildViewController:controller];
  }
  _controller = controller;
  
  if (_controller) {
      UIView *view = _controller.view;
      view.translatesAutoresizingMaskIntoConstraints = NO;
      [self.view addSubview:view];
      ...
      [_controller didMoveToParentViewController:self];
      
      [self updateForcedTraitCollection];
  }
}
}

If the horizontal size we are transitioning to is larger than 320pt, then enforce UIUserInterfaceSizeClassRegular for the horizontalSizeClass. However, this size will be larger than 320pt for any iPhone in landscape size. So in order to limit it to just iPhone 6 and 6 Plus, go back to AppDelegate and add step 4:

1
2
3
4
5
6
7
8
9
10
11
12
...

if (window.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassRegular)
  return UIInterfaceOrientationMaskAllButUpsideDown;

if (window.bounds.size.width > 320)
  return UIInterfaceOrientationMaskAllButUpsideDown;

...

return UIInterfaceOrientationMaskPortrait;
}

Given that it uses a fixed number, this is something I need to have on the checking list for any future major changes, like iOS 9 and/or new devices. Honestly, I can’t help but see this overriding approach as a kludge, the kind I’ve seen too many times in my previous life as web developer. I would love to hear the reasoning as some point – most likely lack of time to develop a more elegant solution. I hope iOS 9 will bring a better way to do this.

But, whatever the case, it sure works as I want it now.