UISplitViewController will not correctly collapse at launch on iPad iOS 13

For some reason on iOS 13 specifically on the iPad in compact traitCollections the call to the delegate to see if it should collapse is happening BEFORE viewDidLoad is called on the UISplitViewController and so when it makes that call, your delegate is not set, and the method never gets called.

If you're creating your splitViewController programmatically this is an easy fix, but if you're using Storyboards not so much. You can work around this by setting your delegate in awakeFromNib() instead of viewDidLoad()

Using your example from the original post, a sample of code would be as follows

class SplitViewController: UISplitViewController, UISplitViewControllerDelegate {
    override func awakeFromNib() {
        super.awakeFromNib()
        delegate = self
        preferredDisplayMode = .allVisible
    }

    func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool {
        return true
    }
}

You'll also want to make sure whatever logic you're using in the collapseSecondary function isn't referencing variables that aren't yet populated since viewDidLoad hasn't been called yet.


I have an Xcode project - now for iOS 13 - that uses a tab bar controller with relationships to five split view controllers, each with their own master detail (table) views and controllers.

Previously - iOS 12.x and earlier, in fact back when I was writing Objective-C - my split view controller delegate was set in code of the master view controller of each (parent) split view controller - I set the delegate in the subclassed UITableViewController's viewDidLoad method. This worked successfully for years on both iPhone and iPad.

e.g.

class MasterViewController: UITableViewController, UISplitViewControllerDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
        splitViewController?.preferredDisplayMode = UISplitViewController.DisplayMode.allVisible
        splitViewController?.delegate = self
        ...
    }

    func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool {
        ...
    }
}

To be clear, I have not subclassed the tab bar controller or the split view controllers.

With the release of Xcode 11 and iOS 13, the split view controller delegate methods in the master view controllers were no longer called.

To be clear, for iOS 13, regardless of device or simulator, splitViewController(_:collapseSecondary:onto:) is not called (tested using breakpoints), with the resulting behaviour:

  • iPhone - detail view controller is presented when app is run on device or simulator.
  • iPad - detail view controller is presented when app is run on device or simulator, without a back button, so there is no obvious mechanism to "escape" the detail view. The only user workaround I found that resolves this problem, is to change device orientation. Following that, the split view controller behaves as expected.

I thought this may have something to do with the new class SceneDelegate.

So I retrofitted a custom SceneDelegate class into my test projects and then my primary project.

I have the custom SceneDelegate class working perfectly. I know this because I successfully set a window?.tintColor in the scene(_:willConnectTo:options:) method.

However the problems with split view controller delegates continued.

I logged feedback to Apple and this is their edited response...

...the problem is that you are setting the UISplitViewController’s delegate in an override of viewDidLoad. It’s possible that the UISplitViewController is deciding to collapse before anything causes its view to be loaded. When it does that, it checks its delegate, but since the delegate is still nil since you haven’t set it yet, your code wouldn’t be called.

Since views are loaded on demand, the timing of viewDidLoad can be unpredictable. In general it’s better to set up things like view controller delegates earlier. Doing it in scene(willConnectTo: session) is likely to work better.

This advice helped me a lot.

In my custom SceneDelegate class I added the following code into the scene(_:willConnectTo:options:) method...

class SceneDelegate: UIResponder, UIWindowSceneDelegate, UISplitViewControllerDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        guard let window = window else { return }
        guard let tabBarController = window.rootViewController as? UITabBarController else { return }

        guard let splitViewController = tabBarController.viewControllers?.first as? UISplitViewController else { return }

        splitViewController.delegate = self
        splitViewController.preferredDisplayMode = UISplitViewController.DisplayMode.allVisible
    }

    ...

    func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool {
        ...
    }

}

This code worked for both iPhone and iPad, but perhaps obviously for only the first split master detail view controller combination.

I changed the code to attempt to achieve this success for all five split view controllers...

    guard let window = window else { return }
    guard let tabBarController = window.rootViewController as? UITabBarController else { return }

    guard let splitViewControllers = tabBarController.viewControllers else { return }

    for controller in splitViewControllers {

        guard let splitViewController = controller as? UISplitViewController else { return }
        splitViewController.delegate = self
        splitViewController.preferredDisplayMode = UISplitViewController.DisplayMode.allVisible
    }

This code works too... almost...

My check for whether to return true for collapseSecondary is based on a unique value - a computed property - from each of the five detail view controllers. Because of this unique check, it seemed difficult to determine this in my custom SceneDelegate class, so in my custom SceneDelegate class, I wrote the following code instead...

    guard let window = window else { return }
    guard let tabBarController = window.rootViewController as? UITabBarController else { return }

    guard let splitViewControllers = tabBarController.viewControllers else { return }

    for controller in splitViewControllers {

        guard let splitViewController = controller as? UISplitViewController else { return }
        guard let navigationController = splitViewController.viewControllers.first else { return }
        guard let masterViewController = navigationController.children.first else { return }
        splitViewController.delegate = masterViewController as? UISplitViewControllerDelegate
        splitViewController.preferredDisplayMode = UISplitViewController.DisplayMode.allVisible
    }

...and then made each detail view controller conform to UISplitViewControllerDelegate.

e.g.

class MasterViewController: UITableViewController, UISplitViewControllerDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
        // the following two calls now in the scene(_:willConnectTo:options:) method...
        // splitViewController?.preferredDisplayMode = UISplitViewController.DisplayMode.allVisible
        // splitViewController?.delegate = self
        ...
    }

    func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool {
        ...
    }
}

So far so good, each of the five split view controllers collapses the detail view at app startup, for both iPhone and iPad.


Well, I think the answer should cover the iOS14 now.

If you find the delegate method is not be called.

func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool {
        ...
}

maybe you should consider to use iOS14's one.

  @available(iOS 14.0, *)
  func splitViewController(_ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column) -> UISplitViewController.Column {
        return .primary
  }