Rotation-locked UISplitViewController on iPhone
I wrote a UISplitViewController category for our app USpace which makes it behave in a slightly different way: it collapses to the right and has a wide “master” portion. When expanded on iPad, it looks like this:
I chose UISplitViewController over a custom solution because the app is universal and has to support everything from the same codebase: iPhone, iPad and iPad split-screen mode, and switch and rotate seamlessly. Another constraint is that it has to be rotation-locked on iPhone (i.e. the app should always be in portrait mode) but at the same time show a specific controller in landscape mode, which prevents us from using the Info.plist interface orientation restrictions.
Most of the custom solutions on GitHub that implement a slide-out horizontal menu do it in a very primitive way, by having a parent controller that slides two views around. USpace view hierarchy is much more variable and complicated to use such a solution, and on top of that, most of them only support iPhone. Besides, I’m a big proponent of rolling your own solution first, to test the water and figure out what features you would actually need. More often than not, you quickly end up with a minimal solution that a) fulfills all your needs b) works perfectly c) is easy to maintain.
We move pretty fast, and my first task was just to create a slide-out controller not unlike one you see on iPad when you swipe from the right side of the screen. It’s called Slide Over if I’m not mistaken. So I just presented a full-screen overlay with a thin controller on the right, and with a bit of work it was working both on iPad and iPhone and was customized to our needs. We quickly grew out of it, setting up the expected view hierarchy was finicky, so I decided to switch the whole app to a UISplitViewController. It took a few days to iron out all the problems (did you know you could put a navigation controller inside another navigation controller in a specific way and have two working navigation bars on top of each other?) but finally it worked, and worked beautifully.
Today I was trying to fix one of the last bugs with this approach, and it was… strange. If you hold the iPhone horizontally (remember that the app is rotation-locked, so everything is sideways) and try opening that one controller which should be displayed in landscape mode, the navigation controller you’re pops your current visible controller and only then presents the landscape-controller. In code it’s just a simple presentViewController(_:). Here’s a sample lldb output:
(lldb) thread backtrace
* thread #1: tid = 0xb08ed2, 0x00000001859e2008 UIKit`-[UINavigationController popViewControllerAnimated:], queue = 'com.apple.main-thread', activity = 'send gesture actions', stop reason = breakpoint 1.1
* frame #0: 0x00000001859e2008 UIKit`-[UINavigationController popViewControllerAnimated:]
frame #1: 0x0000000185bce11c UIKit`-[UINavigationController separateSecondaryViewControllerForSplitViewController:] + 148
frame #2: 0x0000000185e48104 UIKit`-[UISplitViewController _separateSecondaryViewControllerFromPrimaryViewController:] + 360
…
frame #27: 0x000000018592dcec UIKit`-[UIViewController presentViewController:animated:completion:] + 184
I quickly figured out that when trying to present the new controller the app window rotated to landscape orientation and it prompted the wrapper UISplitViewController to split its “secondary” view controller — without asking me, apparently! I made a dive into the docs, and lo and behold, right there on the UISplitViewControllerDelegate page it said:
When you return nil from this method [ splitViewController(_:separateSecondaryViewControllerFromPrimaryViewController:) ], the split view controller calls the primary view controller’s separateSecondaryViewControllerForSplitViewController: method, giving it a chance to designate an appropriate secondary view controller. Most view controllers do nothing by default but the UINavigationController class responds by popping and returning the view controller from the top of its navigation stack.
We’re very interested in the last sentence, because that is exactly what was happening. This only happened on iPhone in landscape layout, because on iPad it didn’t have to split this way, or rather, it did, but it had no effect because the view hierarchy was different when it was allowed.
Still, that was not the end. I returned nil most of the time from the method mentioned in the docs, but this time I had to return a view controller if I didn’t want the navigation controller to pop. I could also subclass the navigation controller (but it’s not future-proof and cumbersome) or prevent it from popping in the navigation controller delegate, but it would be far from the place where the action originated, and the split view controller delegate approach allowed me to keep control in the same file, so there you go.
What I ultimately did is returned a “dummy” view controller from the “splitting” method and and did a return true for this case in the “collapsing” method. What return true means is that you tell the system that you have done what was necessary and you need no further action on its part. Basically it gives you a view controller and says: “Hey, I want to collapse this!” and you reply “Don’t worry, I did everything myself!” while doing absolutely nothing. This way the “collapsing” view controller is just dumped and you never see it again. Again, if you let the system do its job, it will push this controller on top of your navigation controller in the “master” controller.
All in all, it was a fun bug to resolve. Hopefully my explanation maybe helps someone out there :)