52
votes

I have an iOS7 application, which was based on the Xcode master-detail template, that I am porting to iOS8. One area that has changed a lot is the UISplitViewController.

When in portrait mode, if the user taps on the detail view controller, the master view controller is dismissed:

enter image description here

I would also like to be able to programmatically hide the master view controller if the user taps on a row.

In iOS 7, the master view controller was displayed as a pop-over, and could be hidden as follows:

[self.masterPopoverController dismissPopoverAnimated:YES];

With iOS 8, the master is no longer a popover, so the above technique will not work.

I've tried to dismiss the master view controller:

self.dismissViewControllerAnimated(true, completion: nil)

Or tell the split view controller to display the details view controller:

self.splitViewController?.showDetailViewController(bookViewController!, sender: self)

But nothing has worked so far. Any ideas?

10
can you please accept one of the answers below?phatmann

10 Answers

69
votes

Extend the UISplitViewController as follows:

extension UISplitViewController {
    func toggleMasterView() {
        let barButtonItem = self.displayModeButtonItem()
        UIApplication.sharedApplication().sendAction(barButtonItem.action, to: barButtonItem.target, from: nil, forEvent: nil)
    }
}

In didSelectRowAtIndexPath or prepareForSegue, do the following:

self.splitViewController?.toggleMasterView()

This will smoothly slide the master view out of the way.

I got the idea of using the displayModeButtonItem() from this post and I am simulating a tap on it per this post.

I am not really happy with this solution, since it seems like a hack. But it works well and there seems to be no alternative yet.

13
votes

Use preferredDisplayMode. In didSelectRowAtIndexPath or prepareForSegue:

self.splitViewController?.preferredDisplayMode = .PrimaryHidden
self.splitViewController?.preferredDisplayMode = .Automatic

Unfortunately the master view abruptly disappears instead of sliding away, despite the documentation stating:

If changing the value of this property leads to an actual change in the current display mode, the split view controller animates the resulting change.

Hopefully there is a better way to do this that actually animates the change.

12
votes

The code below hides the master view with animation

UIView.animateWithDuration(0.5) { () -> Void in
            self.splitViewController?.preferredDisplayMode = .PrimaryHidden
        }
10
votes

I was able to have the desired behavior in a Xcode 6.3 Master-Detail Application (universal) project by adding the following code in the MasterViewController's - prepareForSegue:sender: method:

if view.traitCollection.userInterfaceIdiom == .Pad && splitViewController?.displayMode == .PrimaryOverlay {
    let animations: () -> Void = {
        self.splitViewController?.preferredDisplayMode = .PrimaryHidden
    }
    let completion: Bool -> Void = { _ in
        self.splitViewController?.preferredDisplayMode = .Automatic
    }
    UIView.animateWithDuration(0.3, animations: animations, completion: completion)
}

The complete - prepareForSegue:sender: implementation should look like this:

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    if segue.identifier == "showDetail" {
        if let indexPath = self.tableView.indexPathForSelectedRow() {
            let object = objects[indexPath.row] as! NSDate
            let controller = (segue.destinationViewController as! UINavigationController).topViewController as! DetailViewController
            controller.detailItem = object
            controller.navigationItem.leftBarButtonItem = self.splitViewController?.displayModeButtonItem()
            controller.navigationItem.leftItemsSupplementBackButton = true

            if view.traitCollection.userInterfaceIdiom == .Pad && splitViewController?.displayMode == .PrimaryOverlay {
                let animations: () -> Void = {
                    self.splitViewController?.preferredDisplayMode = .PrimaryHidden
                }
                let completion: Bool -> Void = { _ in
                    self.splitViewController?.preferredDisplayMode = .Automatic
                }
                UIView.animateWithDuration(0.3, animations: animations, completion: completion)
            }
        }
    }
}

Using traitCollection may also be an alternative/supplement to displayMode in some projects. For example, the following code also works for a Xcode 6.3 Master-Detail Application (universal) project:

let traits = view.traitCollection
if traits.userInterfaceIdiom == .Pad && traits.horizontalSizeClass == .Regular {
    let animations: () -> Void = {
        self.splitViewController?.preferredDisplayMode = .PrimaryHidden
    }
    let completion: Bool -> Void = { _ in
        self.splitViewController?.preferredDisplayMode = .Automatic
    }
    UIView.animateWithDuration(0.3, animations: animations, completion: completion)
}
5
votes

Swift 4 update:

Insert it into prepare(for segue: ...

if splitViewController?.displayMode == .primaryOverlay {
    let animations: () -> Void = {
        self.splitViewController?.preferredDisplayMode = .primaryHidden
    }
    let completion: (Bool) -> Void = { _ in
        self.splitViewController?.preferredDisplayMode = .automatic
    }
    UIView.animate(withDuration: 0.3, animations: animations, completion: completion)
}
2
votes

Modifying the answers above this is all I needed in a method of my detail view controller that configured the view:

 [self.splitViewController setPreferredDisplayMode:UISplitViewControllerDisplayModePrimaryHidden];

Of course it lacks the grace of animation.

1
votes

try

let svc = self.splitViewController
svc.preferredDisplayMode = UISplitViewControllerDisplayMode.PrimaryHidden
0
votes

My solution in the Swift 1.2

  override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath){
    var screen = UIScreen.mainScreen().currentMode?.size.height
    if (UIDevice.currentDevice().userInterfaceIdiom == UIUserInterfaceIdiom.Pad) || screen >= 2000 && UIDevice.currentDevice().orientation.isLandscape == true  && (UIDevice.currentDevice().userInterfaceIdiom == .Phone){
        performSegueWithIdentifier("showDetailParse", sender: nil)
        self.splitViewController?.preferredDisplayMode = UISplitViewControllerDisplayMode.PrimaryHidden
    } else if (UIDevice.currentDevice().userInterfaceIdiom == .Phone) {
        performSegueWithIdentifier("showParse", sender: nil)
    }
}
0
votes

for iPad add Menu button like this

UIBarButtonItem *menuButtonItem = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"burger_menu"]
                                                                       style:UIBarButtonItemStylePlain
                                                                      target:self.splitViewController.displayModeButtonItem.target
                                                                      action:self.splitViewController.displayModeButtonItem.action];
[self.navigationItem setLeftBarButtonItem:menuButtonItem];

This work great with both landscape and portrait mode. To programmatically close the popover vc you just need to force the button action like this

[self.splitViewController.displayModeButtonItem.target performSelector:appDelegate.splitViewController.displayModeButtonItem.action];
0
votes

Very similar to the method by phatmann, but a bit simpler in Swift 5. And it's not technically a 'hack', as it is what the iOS doc suggested.

In your prepareForSegue or other methods that handle touches, in

let barButton = self.splitViewController?.displayModeButtonItem
_ = barButton?.target?.perform(barButton?.action)

According to Apple, the splitViewController's displayModeButtonItem is set up for you to display the master view controller in a way that suits your device orientation. That is, .preferHidden in portrait mode.

All there's to do is to press the button, programatically. Or you can put it in an extension to UISplitViewController, like phatmann did.