Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UISplitViewController will not correctly collapse at launch on iPad iOS 13

I am transitioning my app to iOS 13, and the UISplitViewController collapses onto the detail view, rather than the master at launch — only on iPad. Also, the back button is not shown - as if it is the root view controller.

My app consists of a UISplitViewController which has been subclassed, conforming to UISplitViewControllerDelegate. The split view contains two children — both UINavigationControllers, and is embedded in a UITabBarController (subclassed TabViewController)

In the split view viewDidLoad, the delegate is set to self and preferredDisplayMode is set to .allVisible.

For some reason, the method splitViewController(_:collapseSecondary:onto:) not being called.

In iOS 12 on iPhone and iPad, the method splitViewController(_:collapseSecondary:onto:) is correctly called at launch, in between application(didFinishLaunchingWithOptions) and applicationDidBecomeActive.

In iOS 13 on iPhone, the method splitViewController(_:collapseSecondary:onto:) is correctly called at launch, in between scene(willConnectTo session:) and sceneWillEnterForeground.

In iOS 13 on iPad, however, if the window has compact width at launch e.g. new scene created as a split view, the splitViewController(_:collapseSecondary:onto:) method is not called at all. Only when expanding the window to regular width, and then shrinking is the method called.

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

func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool {
        print("Split view controller function")
        guard let secondaryAsNavController = secondaryViewController as? UINavigationController else { return false }
        guard let topAsDetailController = secondaryAsNavController.topViewController as? DetailViewController else { return false }
        if topAsDetailController.passedEntry == nil {
            return true
        }
        return false
    }
}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        // Setup split controller
        let tabViewController = self.window!.rootViewController as! TabViewController
        let splitViewController = tabViewController.viewControllers![0] as! SplitViewController
        let navigationController = splitViewController.viewControllers[splitViewController.viewControllers.count-1] as! UINavigationController
        navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem
        navigationController.topViewController!.navigationItem.leftBarButtonItem?.tintColor = UIColor(named: "Theme Colour")

        splitViewController.preferredDisplayMode = .allVisible

}
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        if #available(iOS 13.0, *) {
        } else {
            let tabViewController = self.window!.rootViewController as! TabViewController
            let splitViewController = tabViewController.viewControllers![0] as! SplitViewController
            let navigationController = splitViewController.viewControllers[splitViewController.viewControllers.count-1] as! UINavigationController
            navigationController.topViewController!.navigationItem.leftBarButtonItem = splitViewController.displayModeButtonItem
            navigationController.topViewController!.navigationItem.leftBarButtonItem?.tintColor = UIColor(named: "Theme Colour")

            splitViewController.preferredDisplayMode = .allVisible
        }

        return true
    }

It stumps me why the method is being called in iPhone, but not in iPad! I am a new developer and this is my first post, so apologies if my code doesn't give enough detail or is not correctly formatted!

like image 418
Belacqua2000 Avatar asked Aug 28 '19 20:08

Belacqua2000


3 Answers

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.

like image 54
user2898617 Avatar answered Oct 22 '22 04:10

user2898617


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.

like image 28
andrewbuilder Avatar answered Oct 22 '22 02:10

andrewbuilder


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
  }
like image 39
Chen Avatar answered Oct 22 '22 02:10

Chen