Split View Controllers Done Right in iOS 14

A split view controller (UISplitViewController) is a pretty cool thing. It allows you to divide the large iPad screen into two parts, each of which is managed by a view controller in its own right. Typically, these are a master view controller and a detail view controller: on one side, the master side, is a list, and when you tap an item on the list, the corresponding information appears on the other side, the detail side.

In iOS 14, split view controllers come with some big changes — for the better, in my opinion. This article will introduce them.

A Little History

Split view controllers have been around for a long time — basically, ever since the first iPads appeared in 2010 — and certain Apple apps have used them from the outset. The Settings app, for instance, still has essentially the same side-by-side layout on the iPad that it had all those years ago.

At that time, split view controllers were an iPad-only feature, so if your app was a universal app, you had to maintain effectively two completely different interfaces, one for the iPad and one for the iPhone. For instance, I had an app in the App Store starting in iOS 4; I basically had two separate nibs and two different codebases, one for when my app ran on an iPad, where the app’s root view controller was a split view controller, and the other for when it ran on an iPhone, where I used a navigation controller instead. This was challenging and slightly painful to maintain, but it had a kind of clumsy simplicity.

In iOS 8, however, Apple had to grapple with the introduction of the first of what I call the “big iPhones” — the iPhone 6s Plus. This phone was so large that Apple decided to treat it effectively as an iPhone when in landscape orientation but as an iPad when in portrait orientation. So now an app with a UISplitViewController had to cope with the possibility that the user would rotate an iPhone 6s Plus and basically turn an iPhone into an iPad or vice versa.

You Will Adapt

Apple’s solution, starting in iOS 8, was to make the UISplitViewController become adaptive. One and the same view controller, the split view controller, could portray itself either as a split interface showing the master and detail views simultaneously (on the iPad) or as a single navigation controller containing the master view controller, onto which the detail view controller might be pushed (on the iPhone).

That sounds clever, but from the outset, the mechanics were simply horrendous. You can see this for yourself by creating an app from the Master–Detail App template in Xcode 11. There’s a split view controller and its children in the storyboard, but it doesn’t “just work”. On the contrary, there’s also a lot of very mystifying and verbose template code trying to cope with the possibility that the split view controller might launch on either an iPad or an iPhone, or (even worse) might toggle back and forth between the two.

Some of the code now appears in the scene delegate (before iOS 13, this was in the app delegate):

func scene(_ scene: UIScene, 
    willConnectTo session: UISceneSession, 
    options connectionOptions: UIScene.ConnectionOptions) {
        guard let window = window else { return }
        guard let splitViewController = 
            window.rootViewController as? UISplitViewController 
            else { return }
        guard let navigationController = 
            splitViewController.viewControllers.last as? UINavigationController 
            else { return }
        navigationController.topViewController?.navigationItem.leftBarButtonItem = 
            splitViewController.displayModeButtonItem
        navigationController.topViewController?.navigationItem.leftItemsSupplementBackButton = true
        splitViewController.delegate = self
}
func splitViewController(_ splitViewController: UISplitViewController, 
    collapseSecondary secondaryViewController:UIViewController, 
    onto primaryViewController:UIViewController) -> Bool {
        guard let secondaryAsNavController = 
            secondaryViewController as? UINavigationController 
            else { return false }
        guard let topAsDetailController = 
            secondaryAsNavController.topViewController as? DetailViewController 
            else { return false }
        if topAsDetailController.detailItem == nil {
            return true
        }
        return false
}

Some of it appears in the master view controller:

override func viewDidLoad() {
    super.viewDidLoad()
    // ...
    if let split = splitViewController {
        let controllers = split.viewControllers
        detailViewController = 
            (controllers[controllers.count-1] as! UINavigationController).topViewController 
            as? DetailViewController
    }
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == "showDetail" {
        if let indexPath = tableView.indexPathForSelectedRow {
            let object = objects[indexPath.row] as! NSDate
            let controller = (segue.destination as! UINavigationController).topViewController 
                as! DetailViewController
            controller.detailItem = object
            controller.navigationItem.leftBarButtonItem = 
                splitViewController?.displayModeButtonItem
            controller.navigationItem.leftItemsSupplementBackButton = true
            detailViewController = controller
        }
    }
}

That mysterious and clumsy dance is chiefly there to dictate what view controller should be showing when the app switches to an iPhone layout and what buttons should appear at the top of the interface. The annoying thing is that it’s all boilerplate — every split view controller must do this dance, whether it comes from a storyboard or is created entirely in code — and it isn’t even very good boilerplate, as it is easy to discover that there are situations where it doesn’t cope correctly (I won’t go into the gory details).

So basically, in iOS 8, Apple made split view controllers very difficult to deal with, and we’ve all been struggling ever since.

Back to the Future

In iOS 14, split view controllers have been completely rearchitected. In a way, what Apple now provides is a return to the past: as in iOS 7 and before, you have two completely separate view controller hierarchies, one for the iPad and one for the iPhone. The difference is that both view controller hierarchies now belong to the split view controller. Moreover, the buttons just work right out of the box, so there’s no need for boilerplate code to configure them.

The heart of the new architecture is that a split view controller now has columns. On an iPad, these are its .primary and .secondary columns, containing the master and detail view controllers respectively. (There can also be an intermediate third column called the .supplementary column, but let’s ignore that for now.) On an iPhone, it’s the .compact column, and what goes in this column is entirely up to you — it might be a navigation controller, but it doesn’t have to be.

To illustrate, I’ll create a simple split view controller interface. We will have two view controllers:

  • PepListViewController: The master view controller. It is a list of Pep Boys, and the user can select one.

  • OnePepBoyViewController: The detail view controller. It displays the name and picture of the Pep Boy selected in the master view.

For reasons I’m not going to go into here, I like to use a parent view controller as the container for my split view controller. I’ll call this ViewController; it is the app’s root view controller. So here’s how I create and configure the split view controller in the viewDidLoad implementation of ViewController.

First, I create the split view controller itself and put its view into the interface, making sure to do the obligatory parent–child dance:

let split = UISplitViewController(style: .doubleColumn)
self.addChild(split)
self.view.addSubview(split.view)
split.view.frame = self.view.bounds
split.view.autoresizingMask = [.flexibleHeight, .flexibleWidth]
split.didMove(toParent: self)

Now I’ll create the .primary view controller:

let pepList = PepListViewController(isCompact:false)
split.setViewController(pepList, for: .primary)

Notice that I made the PepListViewController itself the .primary view controller. In real life, we want a navigation controller here, so that we get the navigation bar with a place to put buttons. But there’s a secret rule that if we don’t set a column’s view controller as a navigation controller, the runtime will insert a navigation controller for us. So in fact, the primary column will display a navigation controller whose root view controller is the PepListViewController.

Next I’ll create the .secondary view controller:

let pep = OnePepBoyViewController()
let pepNav = UINavigationController(rootViewController: pep)
split.setViewController(pepNav, for: .secondary)

This time, I did create a navigation controller. But I just said we didn’t have to do that, so why did I do it? Well, again, there’s a secret rule. When the user taps a Pep Boy’s name in the PepListViewController, we’re going to call showDetailViewController to change what the split view controller’s .secondary column displays. If we have not created our own navigation controller, the runtime is going to push that new view controller onto the existing navigation controller. This will cause multiple OnePepBoyViewControllers to pile up over time inside the detail navigation stack as the user taps on different Pep Boy names. To prevent that, we do create our own navigation controller, so that we can completely replace it each time the user taps a Pep Boy name.

Finally, the .compact view controller. This is the interface that will appear on the iPhone, and is completely up to us. We want a navigation controller, so we have to provide a navigation controller:

let pepListCompact = PepListViewController(isCompact:true)
let nav = UINavigationController(rootViewController: pepListCompact)
split.setViewController(nav, for: .compact)

It doesn’t get much simpler than that!

We Three Pep Boys

Now let’s look at the master view controller, PepListViewController. It’s a list of the names of the three Pep Boys; I’ll use a collection view list (new in iOS 14). This is effectively boilerplate; the only interesting thing, in my implementation, is the list appearance, which differs depending on whether or not this is the compact incarnation of the view controller:

class PepListViewController: UICollectionViewController {
    var datasource: UICollectionViewDiffableDataSource<String, String>!
    let isCompact : Bool
    init(isCompact:Bool) {
        self.isCompact = isCompact
        super.init(collectionViewLayout: UICollectionViewLayout())
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        self.title = "Pep"
        self.navigationController?.navigationBar.prefersLargeTitles = true
        let config = UICollectionLayoutListConfiguration(
            appearance: self.isCompact ? .plain : .sidebarPlain)
        let layout = UICollectionViewCompositionalLayout.list(using: config)
        self.collectionView.collectionViewLayout = layout
        let reg = UICollectionView.CellRegistration<UICollectionViewListCell, String> { 
            cell, ip, s in
            var contentConfig = cell.defaultContentConfiguration()
            contentConfig.text = s
            cell.contentConfiguration = contentConfig
            if self.isCompact {
                cell.accessories = [.disclosureIndicator()]
            }
        }
        let ds = UICollectionViewDiffableDataSource<String, String>(collectionView:self.collectionView) { 
            cv, ip, s in
            cv.dequeueConfiguredReusableCell(using: reg, for: ip, item: s)
        }
        self.datasource = ds
        var snap = NSDiffableDataSourceSectionSnapshot<String>()
        snap.append(["Manny", "Moe", "Jack"])
        self.datasource.apply(snap, to: "Dummy", animatingDifferences: false)
    }
    // ...
}

Now for the interesting part, namely, what happens when the user taps on the name of a Pep Boy:

override func collectionView(_ collectionView: UICollectionView, 
    didSelectItemAt indexPath: IndexPath) {
        self.respondToSelection(indexPath)
}
fileprivate func respondToSelection(_ indexPath: IndexPath) {
    let snap = self.datasource.snapshot()
    let boy = snap.itemIdentifiers[indexPath.row]
    let pep = OnePepBoyViewController(pepBoy: boy)
    if self.isCompact {
        self.navigationController?.pushViewController(pep, animated: true)
    } else {
        // make our own nav controller so we don't push onto existing stack
        let nav = UINavigationController(rootViewController: pep)
        self.showDetailViewController(nav, sender: self)
    }
}

When we’re on an iPhone, we’re in total charge of what happens; it’s as if there were no split view controller in the story at all. So we just create the OnePepBoyViewController and push it onto our navigation controller. But on an iPad, the split view controller is important; we are the .primary view controller, and we want to replace the .secondary view controller. The way to do that is to call showDetailViewController. Again, we make an explicit navigation controller so that we’re not pushing an additional Pep Boy onto the existing detail navigation controller.

Now I’ll show you OnePepBoyViewController. It’s as simple as can be; it displays the given Pep Boy, defaulting to Manny if no Pep Boy is specified:

class OnePepBoyViewController: UIViewController {
    let boy : String
    @IBOutlet var name : UILabel!
    @IBOutlet var pic : UIImageView!
    init(pepBoy boy:String = "Manny") {
        self.boy = boy
        super.init(nibName: nil, bundle: nil)
    }
    required init(coder: NSCoder) {
        fatalError("NSCoding not supported")
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        self.name.text = self.boy
        self.pic.image = UIImage(named:self.boy.lowercased())
    }
}

Here’s the amazing thing. Our code works perfectly both on iPad and on iPhone! That is the beauty of the new split view controller architecture: it’s incredibly simple. Here it is on the iPad in landscape:

splitViewController

Here it is on the iPhone; these are two different screens, a standard navigation interface:

splitViewController

However, there are some additional situations where our code doesn’t quite work correctly. I’ll talk about that now.

Class Warfare

Up to now, I’ve been talking in terms of how the split view controller behaves on separate device types — how it behaves on the iPad vs. how it behaves on the iPhone. That, however, is not quite accurate. It makes a difference what iPhone we are talking about. As I mentioned earlier, some iPhone models behave like an iPhone in portrait orientation but like an iPad in landscape orientation. I call these the “big” iPhones, and as of this writing, they are the iPhone 6/7/8 Plus, iPhone XR, iPhone XS Max, iPhone 11, iPhone 11 Pro Max, and the iPhone 12 Pro Max.

Technically, these are models where the horizontal size class is .compact in portrait but .regular in landscape. That’s what all this is really about — size classes:

  • When a split view controller finds itself in an environment with a .regular horizontal size class — an iPad, or a “big” iPhone in landscape — it appears, by default, in an expanded state. This means that it displays its .primary and .secondary columns, either side by side or with the primary column overlaying the secondary column.

  • When a split view controller finds itself in an environment with a .compact horizontal size class — an iPhone, or a “big” iPhone in portrait — it appears, by default, in a collapsed state. This means that it displays its .compact column.

This means that when the app orientation changes as the user rotates a “big” iPhone between portrait and landscape, the split view controller will swap, right before the user’s eyes, between being expanded and being collapsed.

And there is another circumstance where the horizontal size class can toggle between .compact and .regular: namely, on the iPad when the app goes into multitasking mode, either as an overlay in front of another app or side by side with another app in splitscreen mode. Again, the split view controller will then swap, before the user’s eyes, between being expanded and being collapsed.

As a test, let’s try our app as it stands on a “big” iPhone and rotate it and see what happens. The answer is that it almost works! The app switches coherently between showing the .primary and .secondary columns, in landscape, and showing the .compact column, in portrait. But there’s one little problem. Suppose the user launches the app in portrait orientation, sees the list of Pep Boys, and taps Jack. Now the user is looking at Jack’s face in the detail view. The user now rotates the device to landscape — and sees Manny’s face! This is bad. The app has lost its place.

The reason is simple: these are two completely independent view controller hierarchies. It is our job to keep track of what the user sees at all times and to make sure that if the horizontal size class toggles, the user ends up seeing the “same” thing in the other view controller hierarchy.

Unfortunately, Apple is unbelievably coy about how we’re supposed to ensure that. In the WWDC 2020 video on this topic, the narrator reaches the point of describing this critical moment of transfer between one view controller hierarchy and the other — and just stops and goes on to a different topic altogether! And of course, there’s no accompanying sample code to help us.

However, from hints dropped in various other places, I believe that what we’re supposed to do is take advantage of two split view controller delegate methods:

func splitViewController(_ svc: UISplitViewController, 
    topColumnForCollapsingToProposedTopColumn 
    proposedTopColumn: UISplitViewController.Column) 
    -> UISplitViewController.Column {
        // ...
        return proposedTopColumn
}
func splitViewController(_ svc: UISplitViewController, 
    displayModeForExpandingToProposedDisplayMode 
    proposedDisplayMode: UISplitViewController.DisplayMode) 
    -> UISplitViewController.DisplayMode {
        // ...
        return proposedDisplayMode
}

These methods will be called, respectively, as the split view controller collapses (the size class has changed to .compact) or expands (the size class has changed to .regular). Our purpose in implementing these methods is not to return a different top column or display mode — in fact, we don’t even have to know what that means — but simply as a signal that we need to transfer the state of the app from one view hierarchy to the other.

Well, in this case, what is the state of the app? It’s what Pep Boy the user is looking at. So I’ll give my ViewController class a chosenBoy instance variable to record that information:

var chosenBoy : String?

And at the end of my PepListViewController respondToSelection method, I’ll add a line that records what Pep Boy the user just selected:

(self.splitViewController?.parent as? ViewController)?.chosenBoy = boy

Back in ViewController, I’ll add a line setting myself as the split view controller’s delegate:

split.delegate = self

To make that line compile, I’ll have to adopt the UISplitViewControllerDelegate protocol:

extension ViewController : UISplitViewControllerDelegate {
    func splitViewController(_ svc: UISplitViewController, 
        topColumnForCollapsingToProposedTopColumn 
        proposedTopColumn: UISplitViewController.Column) 
        -> UISplitViewController.Column {
            delay(0.1) {
                self.swap(svc, collapsing: true)
            }
            return proposedTopColumn
    }
    func splitViewController(_ svc: UISplitViewController, 
        displayModeForExpandingToProposedDisplayMode 
        proposedDisplayMode: UISplitViewController.DisplayMode) 
        -> UISplitViewController.DisplayMode {
            delay(0.1) {
                self.swap(svc, collapsing: false)
            }
            return proposedDisplayMode
    }
}

(The delay is just because the runtime won’t be happy if we fiddle with the view hierarchy in the middle of these delegate methods.) I’ve coded speculatively in my usual carefree fashion, pretending I have a swap(_:collapsing:) method. Now I’ll write it:

func swap(_ svc: UISplitViewController, collapsing: Bool) {
    if collapsing {
        if let boy = self.chosenBoy,
           let nav = svc.viewController(for: .compact) as? UINavigationController {
            let newPep = OnePepBoyViewController(pepBoy: boy)
            nav.popToRootViewController(animated: false)
            nav.pushViewController(newPep, animated: false)
        }
    } else {
        if let boy = self.chosenBoy,
           let list = svc.viewController(for: .primary) as? PepListViewController {
            let newPep = OnePepBoyViewController(pepBoy: boy)
            let nav = UINavigationController(rootViewController: newPep)
            list.showDetailViewController(nav, sender: self)
        }
    }
}

It’s a bit wordy, but what I’m doing here is very simple:

  • If we’re collapsing, my job is to prepare the .compact column. I pop the existing OnePepBoyViewController from the navigation controller and push a new OnePepBoyViewController in its place, showing the correct Pep Boy.

  • If we’re expanding, my job is to prepare the .secondary column. To do that, I tell the .primary column PepListViewController to do exactly what it would have done if the user had tapped on the Pep Boy’s name: I prepare a navigation controller containing a OnePepBoyViewController, showing the correct Pep Boy, and call showDetailViewController.

I can think of other ways to do either of those things, and I really have no idea whether this is the sort of approach Apple intends us to take. But it does work! As we collapse or expand, the interface is replaced, in good order, by the other interface, showing the same Pep Boy. Moreover, even though the example is very simple, if we had more state to display, the difference would be merely a matter of degree. My job is to transfer the “same” state from one view controller hierarchy to the other and configure the interface so that it reflects that state, and that’s exactly what I’m doing.

Also, this being a toy example, I’ve simplified by reusing the same two view controllers, PepListViewController and OnePepBoyViewController, in both view controller hierarchies. The PepListViewController behaves slightly differently depending on whether it was initialized for the .primary or .compact view controller hierarchy, and the OnePepBoyViewController is reused unchanged. But in real life, it would be perfectly possible to have two completely different sets of view controllers, and depending on the circumstances, it might even be simpler to do so.

A Tiny Tweak

In this article, I’ve talked only about the overall architecture of the iOS 14 UISplitViewController, with its dual view controller hierarchy. My goal has been to demonstrate how simple it is to get started with a split view controller, thanks to this architecture and the built-in automatic behavior of the runtime. But of course, there are many details that you can customize. Here’s one example.

There’s something I don’t like about the behavior of our split view controller, namely, what happens when we happen to launch in portrait orientation on an iPad. The problem is that in that orientation, the split view controller uses an overlay to show the .primary column, and the overlay is offscreen at launch time. This means that the user sees the .secondary column with a Back button, and no sense of what we’re supposed to go back to; it feels as if we’ve launched into the middle of the app rather than the start.

To fix that, I’ll implement ViewController’s viewDidLayoutSubviews to show the .primary column just in case we are launching into portrait on an iPad:

var initial = true
override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    if self.initial {
        if self.traitCollection.userInterfaceIdiom == .pad {
            if self.view.bounds.width < self.view.bounds.height {
                if let svc = self.children.first as? UISplitViewController {
                    svc.show(.primary)
                }
            }
        }
    }
    self.initial = false
}

The result, I think, is better.

splitViewController

On to part 2!

You Might Also Like…

Rant: Xcode and the Protocol Paradox

This is a rant about an extremely useful Xcode feature that completely stops working just when you most need it to work. At the risk of giving the whole story away right at the start, I’ll just give the whole story away right at the start! The useful feature is Xcode’s ability to show you …

Rant: Xcode and the Protocol Paradox Read More »

    Sign Up

    Get more articles and exclusive content that takes your iOS skills to the next level.