How I Now Deal with Collapsible Split View Controllers on the iPhone 6
So UISplitViewController
is the new default for iOS apps. Most iPhones have compact-sized size classes. iPads have regular sized ones. The iPhone 6(s) Plus mixes both. Only horizontally regular-sized environments show both the master and the detail scene (or primary and secondary view controller).
When you rotate from portrait to landscape, the “Plus” phones split the screen’s contents like iPads do. That’s because they are not of compact but of regular height. When rotating, this translates to regular width.
So depending on the device orientation, the screen will have one compact and one regular-sized dimension.
Now UIKit provides two methods to deal with this switch, called collapsing and expanding:
- (BOOL)splitViewController:(UISplitViewController *)splitViewController
collapseSecondaryViewController:(UIViewController *)secondaryViewController
ontoPrimaryViewController:(UIViewController *)primaryViewController;
- (UIViewController *)splitViewController:(UISplitViewController *)splitViewController
separateSecondaryViewControllerFromPrimaryViewController:(UIViewController *)primaryViewController;
Handling collapsing is pretty easy. You return YES
or NO
. The three parameters give you plenty of context to make an informed decision.
Note that I let the master view controller implement UISplitViewControllerDelegate
to provide functionality.
For Calendar Paste 3, it looks like this:
- (BOOL)splitViewController:(UISplitViewController *)splitViewController collapseSecondaryViewController:(UIViewController *)secondaryViewController ontoPrimaryViewController:(UIViewController *)primaryViewController
{
if ([secondaryViewController.restorationIdentifier isEqualToString:kViewControllerNoSelection])
{
// Always collapse empty detail
return YES;
}
// Don't collapse paste detail
return NO;
}
There’s a “No selection” detail scene when rotating to landscape. It’s so boring that it should collapse when rotating back to portrait so the table view is visible instead. When there’s meaningful content to the user displayed as detail, this content should become visible when rotating into portrait instead of the table view. Easy.
What should rotating back into landscape do?
- When the app shows the table view list in portrait, show the empty list as detail in landscape.
- When the app shows another view controller in portrait, i.e. when it was pushed onto the stack, show this one as detail in landscape.
The separation-callback doesn’t tell what the secondary view controller is going to be.
How do you find out which detail view controller is currently visible?
My master view controller, which is the delegate, does this:
- (UIViewController *)splitViewController:(UISplitViewController *)splitViewController separateSecondaryViewControllerFromPrimaryViewController:(UIViewController *)primaryViewController
{
if ([self isDisplayingPasteDetailSceneWithPrimaryViewController:primaryViewController])
{
// Keep the detail scene
return nil;
}
// Replace the detail scene with the "empty" scene
return [self emptySelectionNavigationController];;
}
Apart from the hideous method name isDisplayingPasteDetailSceneWithPrimaryViewController:
, it’s still not too complicated to understand what the logic is.
What does it look like, you ask?
- (BOOL)isDisplayingPasteDetailSceneWithPrimaryViewController:(UIViewController *)primaryViewController
{
if ([primaryViewController isKindOfClass:[UINavigationController class]]) {
UINavigationController *navController = (UINavigationController *)primaryViewController;
if (navController.viewControllers.count > 1) {
UINavigationController *detailNavController = (UINavigationController *)navController.viewControllers[1];
id visibleDetailViewController = detailNavController.viewControllers.firstObject;
if ([visibleDetailViewController isKindOfClass:[ShiftAssignmentViewController class]]) {
return true;
}
}
}
return false;
}
It’s a nightmare!
Here, I strongly couple knowledge about implementation details of UISplitViewController
and UINavigationController
into the master view controller.
Fun facts:
- In portrait,
UISplitViewController
has just one entry in itsviewControllers
array. - In landscape, it has two. The second is the detail. That’d help in portrait, too.
- Setting
splitViewController.viewControllers
when in portrait breaks your app. It changes the detail behind the scenes, but at the same time renders segues to any detail scene defunct. (That’s the bug of v3.0.0 I had to fix today.) - In portrait, the master
UINavigationController
knows about the visible detail view controller. After all, the detail is pushed onto the visible navigation stack just as if noUISplitViewController
was used.
After all it seems UISplitViewController
does weird things to the master’s UINavigationController
to achieve its magic.
This makes it reasonable for Apple to not provide a secondaryViewController
parameter in -splitViewController:separateSecondaryViewControllerFromPrimaryViewController:
since the split view controller doesn’t know after the detail was pushed onto the view controller stack of the master.
In this weird environment that’s called iPhone 6(s) Plus, where collapsing and separation of UISplitViewController
s is possible, only the master’s UINavigationController
knows which soon-to-become detail is on top. Reaching up from a view controller is bad. That’s a very strong argument against making any view controller the UISplitViewControllerDelegate
.
Ah, the joys of legacy code.
The delegate should be something else entirely, it seems. An object which encapsulates so-called knowledge about the way UIKit does its thing. (I should call this “assumptions” instead.)
I cannot come up with something more clever. So here it is:
```swift
import UIKit
let kViewControllerNoSelection = “NoSelectionNC”
class CollapsibleSplitViewControllerDelegate: NSObject {
let storyboard: UIStoryboard
init(storyboard: UIStoryboard) {
self.storyboard = storyboard
} }
extension CollapsibleSplitViewControllerDelegate: UISplitViewControllerDelegate {
func splitViewController(
splitViewController: UISplitViewController,
collapseSecondaryViewController secondaryViewController: UIViewController,
ontoPrimaryViewController primaryViewController: UIViewController
) -> Bool {
if let viewControllerIdentifier = secondaryViewController.restorationIdentifier where viewControllerIdentifier == kViewControllerNoSelection {
// Always collapse empty detail
return true
}
// Leave detail on top
return false
}
func splitViewController(
splitViewController: UISplitViewController,
separateSecondaryViewControllerFromPrimaryViewController primaryViewController: UIViewController
) -> UIViewController? {
if isDisplayingPasteDetailScene(primaryViewController: primaryViewController) {
// Keep the detail scene (.None indicates the framework
// will figure out what to do)
return .None
}
return emptySelectionNavigationController()
}
func isDisplayingPasteDetailScene(primaryViewController viewController: UIViewController) -> Bool {
guard let navController = viewController as? UINavigationController, detailNavController = navController.topViewController as? UINavigationController else {
return false
}
guard detailNavController.topViewController is ShiftAssignmentViewController else {
return false
}
return true
}
func emptySelectionNavigationController() -> UIViewController {
return storyboard.instantiateViewControllerWithIdentifier(kViewControllerNoSelection)
} }
It fixes my problems, but it doesn’t make me like UIKit more. The new stuff helps develop apps quickly and all, but it’s a pain to do without producing bad code, apparently.