Search code examples
iosswiftuinavigationcontrolleruitabbarcontrolleruisplitviewcontroller

UINavigationController inside UITabBarController inside UISplitViewController (still) shows detail controller modally instead of pushing


I have what seems to be a very common setup in my universal application, with a root UISplitViewController, using a UITabBarController as a masterViewController, and then I want to:

  • either push the detail view controller onto the stack if I'm on a vertical iPhone
  • show the detail controller in the detailViewController of the UISplitViewController on lanscape iPhone 6+ and other larger screens like iPads and such

To that effect, I have exactly the same setup as the ones described in all those discussions that mention a similar issue:

But none of the solutions mentioned in those questions works. Some of them create an infinite recursive loop and an EXC_BAD_ACCESS. And the latest one I tried simply keeps presenting the detail view controller modally instead of pushing it onto the stack on iPhones. What I did is create a custom UISplitViewController subclass as such:

    class RootSplitViewController: UISplitViewController {
        override func viewDidLoad() {
            super.viewDidLoad()
            self.delegate = self
        }
    }

    extension RootSplitViewController: UISplitViewControllerDelegate {
        func splitViewController(_ splitViewController: UISplitViewController, showDetail vc: UIViewController, sender: Any?) -> Bool {
            if let tabController = splitViewController.viewControllers[0] as? UITabBarController {
                if(splitViewController.traitCollection.horizontalSizeClass == .compact) {
                    tabController.selectedViewController?.show(vc, sender: sender)
                } else {
                    splitViewController.viewControllers = [tabController, vc]
                }
            }

            return true
        }

        func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? {
            if let tabController = splitViewController.viewControllers[0] as? UITabBarController {
                if let navController = tabController.selectedViewController as? UINavigationController {
                    return navController.popViewController(animated: false)
                } else {
                    return nil
                }
            } else {
                return nil
            }
        }
    }

And here is the code in the master view controller to show the detail view controller:

self.performSegue(withIdentifier: "showReference", sender: ["tags": tags, "reference": reference])

Where tags and reference where loaded from Firebase. And of course the "showReference" segue is of the "Show Detail (e.g. Replace)" kind.

The first delegate method is called correctly, as evidenced by the breakpoint that gets hit there when I click an item in the list inside the UITabBarController. And yet the detail view controller still presents modally on iPhone. No problem on iPad though: the detail view controller appears on the right, as expected.

Most of the answers mentioned above are pretty old and some of the solutions are implemented in Objective-C so maybe I did something wrong in the conversion, or something changed in the UISplitViewController implementation since then.

Does anyone have any suggestion?


Solution

  • I figured it out. In fact, it was related to the target view controller I was trying to show. Of the 2 methods I was overriding in UISplitViewControllerDelegate, only the first one was called:

    func splitViewController(_ splitViewController: UISplitViewController, showDetail vc: UIViewController, sender: Any?) -> Bool {
        if let tabController = splitViewController.viewControllers[0] as? UITabBarController {
            if(splitViewController.traitCollection.horizontalSizeClass == .compact) {
                tabController.selectedViewController?.show(vc, sender: sender)
            } else {
                splitViewController.viewControllers = [tabController, vc]
            }
        }
    
        return true
    }
    

    But the view controller I was showing in the first branch of the test was already embedded into a UINavigationController, so I was essentially showing a UINavigationController into another one, and in that case the modal made more sense. So in that case I needed to show the top view controller of the UINavigationController, which I assume was the purpose of the second method I'm overriding in the delegate, but it was never called. So I did it right there with the following implementation:

    extension RootSplitViewController: UISplitViewControllerDelegate {
        func splitViewController(_ splitViewController: UISplitViewController, showDetail vc: UIViewController, sender: Any?) -> Bool {
            if let tabController = splitViewController.viewControllers[0] as? UITabBarController {
                if(splitViewController.traitCollection.horizontalSizeClass == .compact) {
                    if let navController = vc as? UINavigationController, let actualVc = navController.topViewController {
                        tabController.selectedViewController?.show(actualVc, sender: sender)
                        navController.popViewController(animated: false)
                    } else {
                        tabController.selectedViewController?.show(vc, sender: sender)
                    }
                } else {
                    splitViewController.viewControllers = [tabController, vc]
                }
            }
    
            return true
        }
    }
    

    And that seems to work perfectly, both on iPhones and iPads