I’ve recently been working on a UISplitViewController-esque view controller based around the requirements of my app (more on what’s wrong with UISplitViewController in a later post). Because we support Adaptive Layout, I’m adding a behavior where when the view controller gets too narrow, it merges the master and detail navigation controllers into master, and when it gets wide again, it splits them back out again.
This works fine in during size transitions (rotating the device or using iPad split view) but can fail with the following exception when state restoration crosses the size boundary:
child view controller:<SEQuestionAndAnswerViewController: 0x7ff4e780e000> should have parent view controller:<SEUINavigationController: 0x7ff4e5035400> but requested parent is:<SEUINavigationController: 0x7ff4e7009000>
Or for clarity:
child view controller: THING I'M MOVING should have parent view controller: SOURCE NAVIGATION CONTROLLER but requested parent is: TARGET NAVIGATION CONTROLLER
So, what’s going on here? If I check the child view controller’s parent, it’s nil, so something weird is happening. Why does UIKit think the source navigation controller owns the child?
Figuring out what’s wrong
To answer this question, I like to turn to my trusty source for undocumented behavior, Hopper Disassembler. Hopper is a tool for inspecting executables and libraries, which can take machine code and translate it into somewhat readable Objective-C.
First I find the throwing method in the call stack,
It looks truly terrible in Xcode but does give us some hints, selectors like
_viewControllerForAncestors are used before the exception. I could start trying probing the value of each of those before continuing, but let’s see that same method in Hopper:
That’s more or less readable. The hierarchy check is as follows:
- If the view controller has a parent view controller, it checks out. I don’t really understand this, but I’ll go with it.
- Otherwise, if the view controller hasn’t loaded its view, or the view isn’t in a window, we’re good.
- If neither of those check out, we get call
[child.view.superview _viewControllerForAncestor]to get the superview’s view controller. If this view controller is not nil and not the new parent, then we crash.
This is pretty easy to check in the debugger. Calling the method gets us what we want. Unfortunately, we don’t have
_viewControllerForAncestor available to us, but we can fake it by calling
nextResponder over and over until we get a view controller:
This is good, because now we can check if we’re going to crash before we actually do. But what can we do about it?
Putting together a solution
There are two solutions. One would be to call
viewController.view.removeFromSuperview() before moving it, but who knows what that will break inside
UINavigationController. The other is to have faith that eventually
UINavigationController will let go of the view and then we can finish what we’re doing.
I went with the later and it looks like they’re let go in the next run of the main loop, so
DispatchQueue.main.async is all I need before I’m ready to update my view. The only problem is that one frame renders with my view controller missing. I’m just putting a blank navigation controller there so it looks like the content is just loading (which, in a sense, it is).
My final code looks something like this:
Looping back to one thing I I glossed over. Why is this happening and why doesn’t it happen during size changes? I haven’t fully looked into it, but if you look at
-[UINavigationController setViewController:animated:] (which is not nearly as cleanly disassembled), you’ll see that transitions can be deferred under certain circumstances, and
needsDeferredTransition is true, so that’s probably what happened.
It doesn’t (can’t?) do it during size changes because
-[UIViewController viewWillTransitionToSize:withTransitionCoordinator:] is called within
+[UIViewController _performWithoutDeferringTransitions:]. Why state restoration isn’t is anyone’s guess.