Multiple UISplitViewController Tutorial

This UISplitViewController tutorial shows you how to build an adaptive layout note-taking app using multiple UISplitViewControllers.

Version

  • Swift 4.2, iOS 12, Xcode 10

An essential part of a modern iOS app is the need to adapt to the device and environment that it’s run on. An app that runs on iPad has a lot of horizontal space to play with unless it’s run in Split Screen or Slide Over column mode. Your app should adapt to all possible layouts with grace.

UISplitViewController is a very versatile component that can adapt to all trait collections with very little need for modification by the developer for simple cases. In this tutorial, you’ll learn the simple case, after which you’ll double the fun by placing a split view inside your split view, to add an extra level of hierarchy!

Getting Started

Download the starter app using the Download Materials button at the top or bottom of the page.

The project app, TreeWorld, is similar to the Notes app – there is a simple file system with folders, that can contain files. The files have text that you can edit.

Take a look around the project first. Open the TreeWorld.xcodeproj file in the TreeWorld-Starter folder. Expand the TreeWorld folder.

expanded project

Model

Expand the Model Layer folder. Inside is a Core Data model with two entities:

  • File represents a node in the file system.
  • Content represents a container for text.

core data model

Xcode automatically generates File.swift and Content.swift so that you can use them in the project.

CoreDataStack.swift is a simple Core Data setup that also saves the model when you background the app.

The model obeys these arbitrary rules:

  1. A folder is a File that has isFolder set to true.
  2. A folder never has a Content object.
  3. A folder can’t contain folders.
  4. A file is a File that has isFolder set to false.
  5. A folder can contain any number of child files.
  6. A file must have a Content object.
  7. A file never has children.

FileDataSource.swift is a set of query and business logic for File objects, and it embodies the rules above.

Views

Inside the group Presentation Layer ▸ Member View Controllers is a set of pre-made view controllers and storyboards that you’ll use in this app.

EditorViewController.swift is a view to allow you to edit the Content object of a File.

FileListViewController.swift is a UITableViewController to present File objects. The FileDataSource class provides the UITableViewDataSource and UITableViewDelegate implementations to keep unnecessary logic out of the view controller.

PlaceholderViewController.swift displays a message.

You can investigate them later if you want. They are composed of familiar UIKit components and should have no surprises for you.

Inside Presentation Layer ▸ Root View Controller is RootViewController.swift. This file is where you’ll make most of the changes in this tutorial. It will act as a coordinator for all the views.

Choose the iPad Pro (9.7-inch) simulator in the target settings. Build and run.

target settings

You should see a green view appear. You’re ready to start splitting, here.

initial starter state

Installing the Root Split View Controller

In this section, you’ll create and install the root UISplitViewController that will exist for all layouts.

Creating a Split View Controller

Select the folder Presentation Layer ▸ View Controllers ▸ Root View Controller in the Project navigator. Add a new file with Command-N (or File ▸ New ▸ New File…).

New file dialog

New file dialog commit

  1. Select a new Swift File.
  2. Click Next.
  3. Name the file RootViewController+ViewFactory.
  4. Click Create.

Open RootViewController+ViewFactory.swift and add the following code:

import UIKit

extension RootViewController {
  func freshSplitViewTemplate() -> UISplitViewController {
    let split = UISplitViewController()
    split.preferredDisplayMode = .allVisible
    let navigation = UINavigationController()
    split.viewControllers = [navigation]
    return split
  }
}

Here, you instantiate a UISplitViewController and a UINavigationController. You then set the navigation controller as the primary view controller of the split.

split view primer

UISplitViewController has a primary and a secondary view controller. You can set and get these controllers via the viewControllers property.

To ensure both primary and secondary views are visible at the same time, you set the preferredDisplayMode property to .allVisible.

Open RootViewController.swift and add this code to the body of the main class:

let rootSplitSmallFraction: CGFloat = 0.25
let rootSplitLargeFraction: CGFloat = 0.33

lazy var rootSplitView: UISplitViewController = {
  let split = freshSplitViewTemplate()
  split.preferredPrimaryColumnWidthFraction = rootSplitLargeFraction
  split.delegate = self
  return split
}()

override func viewDidLoad() {
  super.viewDidLoad()
  installRootSplit()
}

func installRootSplit() {
  view.addSubview(rootSplitView.view)
  view.pinToInside(rootSplitView.view)
  addChild(rootSplitView)
}

Add this extension to the end of the file:

extension RootViewController: UISplitViewControllerDelegate {
  //you'll add to this later
}

In this fragment, you create a UISplitViewController and embed it within the RootViewController. You also set the primary view to take 33% of the available width.

Build and run. You should see the empty navigation bar for the primary view in the top-left. There’s not much to look at, yet.

initial split install

Managing State

You now need to set up some infrastructure. The app can have three possible selection states that you want to keep track of:

  1. Nothing selected
  2. Folder selected
  3. File selected

Select the folder Presentation Layer ▸ View State in the Project navigator. Create a new Swift file (Command-N) and name it SelectionState.

Add this code to the file:

enum SelectionState {
  case noSelection
  case folderSelected
  case fileSelected
}

Now, create a second file in the View State folder. Name this file StateCoordinator.swift.

Add this code to the body of StateCoordinator.swift:

import UIKit

// 1
protocol StateCoordinatorDelegate: class {
  func gotoState(_ nextState: SelectionState, file: File?)
}

// 2
class StateCoordinator: NSObject {
  // 3
  private(set) var state: SelectionState = .noSelection
  private weak var delegate: StateCoordinatorDelegate?
  
  init(delegate: StateCoordinatorDelegate) {
    self.delegate = delegate
  }
  
  // 4
  private(set) var selectedFile: File? {
    didSet {
      guard let file = selectedFile else {
        state = .noSelection
        return
      }
      state = file.isFolder ? .folderSelected : .fileSelected
    }
  }

  // 5
  var selectedFolder: File? {
    guard let file = selectedFile else {
      return nil
    }
    return file.isFolder ? file : file.parent
  }  
}

Reviewing step by step:

  1. First, you declare a delegate protocol, StateCoordinatorDelegate, to allow another object to react to changes in the StateCoordinator.
  2. Next, you declare the StateCoordinator class. This class has the job of keeping track of the current selected File. The class will change to the appropriate SelectionState when a File is selected or deleted.
  3. StateCoordinator has two properties: one for SelectionState that is publicly read-only and the other that is a weak reference to the StateCoordinatorDelegate.
  4. The selectedFile property is publicly read-only and uses a property observer to change the state.
  5. The selectedFolder property is computed and allows you to get the currently selected top-level folder.

Now, add this extension to the end of StateCoordinator.swift:

extension  StateCoordinator {
  func didSelectFile(_ file: File?) {
    selectedFile = file
    delegate?.gotoState(state, file: selectedFile)
  }
  
  func didDeleteFile(parentFolder: File?) {
    selectedFile = parentFolder
    state = .noSelection
    delegate?.gotoState(state, file: selectedFile)
  }
}

Here, you have two public APIs to allow callers to change the state in a managed way.

state coordinator api

didSelectFile(_:) tells the StateCoordinator that a File was selected. This has the effect of setting the state and notifying the downstream delegate that this happened.

didDeleteFile(parentFolder:) is almost the same as didSelectFile, but it ensures that the state is set to noSelection before notifying the delegate.

Updating the Root View Controller

RootViewController needs to own a StateCoordinator to coordinate state changes.

Open RootViewController.swift.

Add this extension to the end of RootViewController.swift:

extension RootViewController: StateCoordinatorDelegate {
  func gotoState(_ nextState: SelectionState, file: File?) {

  }
}

Add these two properties above viewDidLoad():

let dataStack = CoreDataStack()
lazy var stateCoordinator: StateCoordinator = StateCoordinator(delegate: self)

Here, you add a StateCoordinator instance to RootViewController and ensure that RootViewController conforms to StateCoordinatorDelegate. RootViewController can now react to selection changes within the app.

The dataStack will hold the data that you create in the app.

Updating the File Data Source

The FileDataSource will want to send selection actions to the StateCoordinator. You need to add a property to FileDataSource.

Open the folder Model Layer in the Project navigator. Open FileDataSource.swift.

Add this property to the top of the main class above init(context:presenter:rootFolder:):

var stateCoordinator: StateCoordinator?

Installing the File List Table View

You now have almost enough structure to install a FileListViewController in the root split view. First, you need to add a couple of helpers to the view factory extension.

Updating the View Factory

Open RootViewController+ViewFactory.swift.

Add these methods to the body of the extension:

func configureFileList(_ fileList: FileListViewController,
                       title: String,
                       rootFolder: File?) {
  let datasource = FileDataSource(context: dataStack.viewContext,
                                  presenter: fileList,
                                  rootFolder: rootFolder)
  datasource.stateCoordinator = stateCoordinator
  fileList.fileDataSource = datasource
  fileList.title = title
}

func primaryNavigation(_ split: UISplitViewController)
  -> UINavigationController {
  guard let nav = split.viewControllers.first
    as? UINavigationController else {
    fatalError("Project config error - primary view doesn't have Navigation")
  }
  return nav
}

The method configureFileList(_:title:rootFolder:) takes a FileListViewController, creates a FileDataSource and assigns it to the FileListViewController. The FileDataSource is given a reference to the StateCoordinator.

Next, primaryNavigation(_:) recovers the UINavigationController from the primary view of a split view. For this app, the navigation controller should always exist, so you throw a fatalError to warn you when a bug has been created.

Installing the File List

Now, you’re ready to install the FileListViewController.

Open RootViewController.swift and find the method installRootSplit().

Add this code after the line addChild(rootSplitView):

let fileList = FileListViewController.freshFileList()
let navigation = primaryNavigation(rootSplitView)
navigation.viewControllers = [fileList]
configureFileList(fileList, title: "Folders", rootFolder: nil)

In this fragment, you instantiate a FileListViewController from a storyboard. You then recover the UINavigationController from the split view and assign the file list as the navigation’s root view controller.

Build and run. You can now see the FileListViewController inside a parent UINavigationController.

root folder list

You can add folders to the database, rename with a swipe-right and delete with a swipe-left.

Place the app in the background (Hardware ▸ Home) to trigger a save.

Note: Most of the logic in FileDataSource is taken from the Xcode Master-Detail App + Core Data project template. Check out this Core Data tutorial if you want to learn more about Core Data and NSFetchedResultsController.

Filling in the Detail

Take a look at all that empty space to the right of the table. In this part of the tutorial, you’re going to fill in that space with cool content.

Am I Horizontally Regular?

The iOS view system uses a system of size classes to let the developer know how to layout views. The size class for a view can change at any time either by system or user interaction. As of iOS 12, the size class for a given axis can be compact or regular.

As a rough rule of thumb, all iPad full screen and two-thirds width split screen are regular and the rest are compact. In most conditions, modern iOS apps should no longer modify layout based on device type or screen size.

You want this app to react to size class changes depending on whether or not the app is being run in a horizontally compact or regular environment.

Add this extension to the top of RootViewController.swift below import UIKit:

extension UIViewController {
  var isHorizontallyRegular: Bool {
    return traitCollection.horizontalSizeClass == .regular
  }
}

This code is a convenience method to tell you whether or not the app is in regular layout on the horizontal axis.

Adding Placeholder Views

When there is nothing selected, you’ll want to fill in the detail with a placeholder.

Open RootViewController+ViewFactory.swift and add this extension:

extension RootViewController {
  func freshFileLevelPlaceholder() -> PlaceholderViewController {
    let placeholder = PlaceholderViewController
      .freshPlaceholderController(
        message: "Select file or create. Swipe left to delete")
    return placeholder
  }
  
  func freshFolderLevelPlaceholder() -> PlaceholderViewController {
    let placeholder = PlaceholderViewController
      .freshPlaceholderController(message: """
        Select folder or create. Swipe left to delete or swipe right to rename
        """)
    return placeholder
  }
}

These two methods create PlaceholderViewController instances with contextual messages.

Return to RootViewController.swift.

Add this extension to the end of the file:

extension RootViewController {
  func showFileLevelPlaceholder(in targetSplit: UISplitViewController) {
    if isHorizontallyRegular {
      targetSplit.showDetailViewController(freshFileLevelPlaceholder(), sender: self)
    }
  }
  
  func showFolderLevelPlaceholder(in targetSplit: UISplitViewController) {
    if isHorizontallyRegular {
      rootSplitView.preferredPrimaryColumnWidthFraction = rootSplitLargeFraction
      targetSplit.showDetailViewController(freshFolderLevelPlaceholder(), sender: self)
    }
  }
}

These two methods will install a placeholder view in the secondary view of a split view but only if the current trait collection is regular. In a compact environment, you’ll never see the placeholders, because if you have nothing selected in the list, you’ll still be looking at the list.

You’re building the set of components you need to handle all trait collections. Soon, all your hard work will pay off!

Handling the Navigation States

You’ll want to know the state of the navigation controller in the rootSplitView. In this section, you’ll add logic to help with that.

In RootViewController.swift, add this enum to the main RootViewController class definition:

enum NavigationStackCompact: Int {
  case foldersOnly = 1
  case foldersFiles = 2
  case foldersFilesEditor = 3
}

This enum describes all the possible stacks that can exist in a compact environment.

Add this extension to the end of RootViewController.swift:

extension RootViewController: UINavigationControllerDelegate {
  func navigationController(_ navigationController: UINavigationController, 
                            didShow viewController: UIViewController, 
                            animated: Bool) {
    if navigationStack(navigationController, isAt: .foldersOnly) {
      showFolderLevelPlaceholder(in: rootSplitView)
    }
  }
  
  func navigationStack(
    _ navigation: UINavigationController, 
    isAt state: NavigationStackCompact
  ) -> Bool {
    let count = navigation.viewControllers.count
    if let value = NavigationStackCompact(rawValue: count) {
      return value == state
    }
    return false
  }
}

This delegate method, navigationController(_:viewController:animated:), allows you to detect a change to the navigation stack and install a placeholder when needed.

The helper method navigationStack(_:isAt:) in combination with NavigationStackCompact returns an answer to the question, “What is the navigation showing right now?” without splashing magic numbers across the code.

Finally, find the method installRootSplit() in the class definition and add this line at the end of the method:

navigation.delegate = self

Build and run. You should see the placeholder appear.

folder placeholder installed

Splitting the Split

You’re now ready to install a UISplitViewController inside a UISplitViewController.

Creating a Split and Adding to View

You want to place a UISplitViewController inside rootSplitView like this:

Add a subsplit

In RootViewController.swift, insert this method below viewDidLoad():

func installDoubleSplitWhenHorizontallyRegular() -> UISplitViewController? {
  guard isHorizontallyRegular else {
    return nil
  }
  
  if let subSplit = rootSplitView.viewControllers.last
    as? UISplitViewController {
    return subSplit
  }
  
  let split = freshSplitViewTemplate()
  split.delegate = self
  rootSplitView.preferredPrimaryColumnWidthFraction = rootSplitSmallFraction
  rootSplitView.showDetailViewController(split, sender: self)
  return split
}

This method checks to see if there is a split view installed in the secondary view, and it returns the if it exists. Otherwise, a new split view is created and returned.

Choosing the Correct Split to Use

When you add the list of files contained in a folder, you’ll target a different split view depending on the current trait collection. This will happen in several places, so you’ll place all that logic inside a helper method.

Add this method below the method installDoubleSplitWhenHorizontallyRegular():

func targetSplitForCurrentTraitCollection() ->
 UISplitViewController {
  if isHorizontallyRegular {
    guard let subSplit = installDoubleSplitWhenHorizontallyRegular() else {
      fatalError("you must have a UISplitViewController here")
    }
    return subSplit
  } else {
    return rootSplitView
  }
}

This method always returns the correct split view depending on the trait collection.

Adding the Table View of Files

The list of folders will always appear in the primary navigation controller of rootSplitView, but the list of files can either appear:

  • Stacked in the primary navigation.
  • As the root view controller of the sub-split’s primary navigation.

compact vs regular

In RootViewController.swift, find the extension that contains the method showFolderLevelPlaceholder().

Add this code inside the extension:

func installFileList(fileList: FileListViewController) {
  if 
    isHorizontallyRegular,
    let subSplit = installDoubleSplitWhenHorizontallyRegular() {
      //1
      let navigation = primaryNavigation(subSplit)
      navigation.viewControllers = [fileList]
      //2
      subSplit.preferredDisplayMode = .allVisible
      subSplit.preferredPrimaryColumnWidthFraction = rootSplitLargeFraction
      rootSplitView.preferredPrimaryColumnWidthFraction = rootSplitSmallFraction
      //3
      showFileLevelPlaceholder(in: subSplit)
  } else {
    let navigation = primaryNavigation(rootSplitView)
    navigation.pushViewController(fileList, animated: true)
  }
}

When the app has a regular horizontal width:

  1. You install the FileListViewController as the root view of the sub-split’s UINavigationController.
  2. You configure the split’s preferredPrimaryColumnWidthFraction property to divide the screen 25/25/50.
  3. You install a placeholder in the detail view.

Otherwise, not in a regular horizontal width, you push the FileListViewController onto the UINavigationController in the rootSplitView.

Responding to State Changes

Earlier, you configured RootViewController as the delegate of the StateCoordinator. The StateCoordinator calls gotoState(_:file:) on its delegate when the selection state changes.

In this next section, you’ll add the glue code to allow the UI to react to those changes.

Configuring Root View to Respond to State Changes

Within RootViewController.swift, find the method gotoState(_:file:) in the StateCoordinatorDelegate extension.

Add these four helpers below that method:

//1
func gotoNoSelection(_ folder: File?) {
  let navigation = primaryNavigation(rootSplitView)
  if navigationStack(navigation, isAt: .foldersOnly) && folder == nil {
    showFolderLevelPlaceholder(in: rootSplitView)
  } else {
    showFileLevelPlaceholder(in: targetSplitForCurrentTraitCollection())
  }
}

//2
func gotoFolderSelected(_ folder: File) {
  if folder.isFolder {
    let fileList = FileListViewController.freshFileList()
    let title = folder.name ?? "Untitled"
    configureFileList(fileList, title: title, rootFolder: folder)
    installFileList(fileList: fileList)
  }
}

//3
func gotoFileSelected(_ file: File) {
  if !file.isFolder {
    let detail = EditorViewController.freshDetailController(file: file)
    let navigation = freshNavigationController(rootViewController: detail)
    targetSplitForCurrentTraitCollection()
      .showDetailViewController(navigation, sender: self)
  }
}

//4
func freshNavigationController(rootViewController: UIViewController) 
  -> UINavigationController {
  let nav = UINavigationController(rootViewController: rootViewController)
  nav.navigationBar.prefersLargeTitles = true
  return nav
}
  1. gotoNoSelection(_:) checks the state of the root navigation and makes a decision regarding which placeholder to install. This method is called when the user has deleted a file or a folder. When the user deletes a File, the StateCoordinator passes the parent File to the StateCoordinatorDelegate. A folder has no parent, so the file argument will be nil.
  2. gotoFolderSelected(_:) creates a new FileListViewController, configures that file list with a reference to the selected parent folder, and then it installs the file list in the correct place.
  3. gotoFileSelected(_:) creates a new EditorViewController with a reference to the selected file, places that editor inside a UINavigationController, and then it installs that navigation into the secondary view of the relevant split.
  4. freshNavigationController(rootViewController:) creates a new UINavigationController instances with the correct configuration.

Finally, you’ll fill in the body of gotoState(_:file:) with this code:

if nextState == .folderSelected, let folder = file {
  gotoFolderSelected(folder)
} else if nextState == .fileSelected, let file = file {
  gotoFileSelected(file)
} else if nextState == .noSelection {
  gotoNoSelection(file)
}

With this code, you pick the correct destination based on the nextState argument.

You have finished with the RootViewController for now!

Updating File Data Source to Trigger State Changes

Responding to Selections

The RootViewController can now react to the changes broadcast by the StateCoordinator but nothing is triggering those changes yet.

In this section, you’ll add the code to let FileDataSource connect to StateCoordinator.

File data source triggers changes

Open the Model Layer folder in the Project navigator and select FileDataSource.swift.

Find the extension extension FileDataSource: UITableViewDelegate.

Add this code inside the body of the method tableView(_:didSelectRowAt:):

stateCoordinator?.didSelectFile(object(at: indexPath))

When you touch a cell in FileListViewController, the delegate method tableView(_:didSelectRowAt:) in FileDataSource is called.

In turn, FileDataSource tells the StateCoordinator that a File was selected, at which point the StateCoordinator changes state and tells the RootViewController about that change.

Responding to Deletions

You also want to address the deletion of files as that action can change the selection state. In this section, you’ll add the code to address this.

Find the extension extension FileDataSource: UITableViewDataSource.

Find the method deleteAction(_:) and replace the line try operations.delete(file: file, force: true) inside the do block with this code:

//1
let parent = file.parent
//2
try operations.delete(file: file, force: true)
//3
stateCoordinator?.didDeleteFile(parentFolder: parent)

In this change, you:

  1. Get a reference to the parent of the File before deleting from the database.
  2. Delete the file.
  3. Tell the StateCoordinator that a deletion happened.

Build and run to see all your hard work pay off.

finished embedding split

When you select a folder, a second FileListViewController appears wherein you can create, rename and delete file objects.

Testing in Compact Environments

Thanks to UISplitViewController, your app will also adapt to running on iPhone.

Pick the iPhone 8 simulator target.

iPhone 8 target

Build and run. Your UISplitViewController now behaves like a UINavigationController. You’ll start with an empty folder list, because this is a new device. Add a folder:

compact folders

Select it and go to the files list, where you can add files:

compact files

Select a file to go to the editor:

compact editor

That’s great! When running in a compact environment, you see one view at a time, because there isn’t room for more. That’s the power of UISplitViewController!

Ensuring Correct Behavior When Traits Change

So far, you’ve set up the app to behave correctly when launched in either a compact or regular environment.

You saw that a UISplitViewController could adapt itself to either a compact or regular layout. When you place a view controller in the secondary view with showDetailViewController(_:sender:):

  • In compact mode, UISplitViewController shows the detail view on the top of the navigation stack and adds a Back button to the navigation bar.
  • In regular mode, UISplitViewController places the detail view in the right-hand split.

Currently, if you try to go from regular mode to compact mode — or vice versa — the app will not work correctly. This will affect your users when they are using your app in split screen multitasking modes on iPad.

UISplitViewController needs some hints on how to reassemble itself during a trait change.

reassembly for trait change

In this section, you’ll add the code to allow the app to respond correctly to the change from compact to regular.

Controlling the Reassembly

UISplitViewControllerDelegate has a large set of methods that allow you to customize the behavior of UISplitViewController. You’ll use two that are called when the split needs to change its trait collection.

First, you’ll add helpers to the view factory.

Find RootViewController+ViewFactory.swift in the Project navigator (ViewControllers ▸ RootViewController).

Add this extension to the file:

extension RootViewController {
  func rootFileList(_ split: UISplitViewController)
      -> FileListViewController? {
    let navigation = primaryNavigation(split)
    guard let fileList = navigation.viewControllers.first as? 
      FileListViewController else {
      assertionFailure("Your split should have a FileListVC in the master nav")
      return nil
    }
    return fileList
  }
  
  
  func activeEditor(_ split: UISplitViewController) -> EditorViewController? {
    guard let navigation = split.viewControllers.last as? UINavigationController,
      let editor = navigation.viewControllers.first as? EditorViewController
      else { return nil }
    return editor
  }
  
}

These methods are helpers used to extract either a FileListViewController or an EditorViewController from a UISplitViewController.

Changing From Regular to Compact Traits

Now, you’ll manage the change from regular to compact traits.

Open RootViewController.swift. Locate the extension:

extension RootViewController: UISplitViewControllerDelegate

Add this method inside the body of the extension:

func splitViewController(_ splitViewController: UISplitViewController,
                         collapseSecondary secondaryViewController: UIViewController,
                         onto primaryViewController: UIViewController) -> Bool {
  //1
  let primaryNav = primaryNavigation(splitViewController)
  var currentStack = primaryNav.viewControllers
  
  //2
  if let secondarySplit = secondaryViewController as? UISplitViewController {
    //3
    if let fileList = rootFileList(secondarySplit) {
      currentStack.append(fileList)
    }
    //4
    if let editor = activeEditor(secondarySplit) {
      currentStack.append(editor)
    }
    //5
    primaryNav.viewControllers = currentStack
    return true
    
  } else if let folderList = currentStack.first {
    //6
    primaryNav.viewControllers = [folderList]
    return true
    
  }
  return false
}

This delegate method splitViewController(_:collapseSecondary:onto:) allows you to control the process of collapsing the secondary controller onto the primary. You return a result of true if you want to override the default behavior.

  1. First, you get a reference to the root navigation controller and take a snapshot of its view controller stack.
  2. Next, you check if there’s a split in the secondary position.
  3. You append the FileListViewController from that split onto the destination stack.
  4. If the secondary view in the secondary split is an EditorViewController, you append that also.
  5. Finally, reset the navigation stack with its new contents.
  6. When there’s no split in the secondary position, you reset the navigation stack with only the folder list.

And that’s it! You have covered the case of the trait collection changing from regular to compact.

Changing From Compact to Regular Traits

Now, you’ll manage the change from compact to regular traits.

Add this code to the UISplitViewControllerDelegate extension:

func splitViewController(_ splitViewController: UISplitViewController,
                         separateSecondaryFrom primaryViewController: UIViewController)
    -> UIViewController? {
  
  guard let primaryNavigation = primaryViewController as? UINavigationController else {
    return nil
  }
  
  return decomposeStackForTransitionToRegular(primaryNavigation)
}

func decomposeStackForTransitionToRegular(_ navigationController: UINavigationController)
    -> UIViewController? {
  //1
  let controllerStack = navigationController.viewControllers
  guard let folders = controllerStack.first else {
    return nil
  }
  
  //2
  defer {
    navigationController.viewControllers = [folders]
    rootSplitView.preferredPrimaryColumnWidthFraction = rootSplitSmallFraction
  }
  
  //3
  if navigationStack(navigationController, isAt: .foldersOnly) {
    //folder list only was presented  - return a placeholder
    return freshFolderLevelPlaceholder()
    
  } else if navigationStack(navigationController, isAt: .foldersFiles) {
    //folders and file list was presented  - return a split with files + placeholder
    let filesAndPlaceholder = configuredSplit(
      first: controllerStack[1], 
      second: freshFileLevelPlaceholder())
    return filesAndPlaceholder
    
  } else if navigationStack(navigationController, isAt: .foldersFilesEditor) {
    //folders, files and editor was presented  - return a split with files + editor
    let filesAndEditor = configuredSplit(
      first: controllerStack[1], 
      second: controllerStack[2])
    return filesAndEditor
  }
  
  return nil
}

func configuredSplit(first: UIViewController, second: UIViewController)
    -> UIViewController {
  let freshSplit = freshSplitViewTemplate()
  freshSplit.preferredPrimaryColumnWidthFraction = rootSplitLargeFraction
  let fileNavigation = primaryNavigation(freshSplit)
  fileNavigation.viewControllers = [first]
  freshSplit.viewControllers = [fileNavigation, second]
  return freshSplit
}

The delegate method splitViewController(_:separateSecondaryFrom:) allows you to return the UIViewController that is placed in the secondary position when transitioning to regular traits.

In this code above, you cast the primaryViewController as UINavigationController, then you pass that controller to decomposeStackForTransitionToRegular(_:):

  1. Take a copy of the current navigation stack and root view controller in that stack.
  2. Use defer to reset the input UINavigationController to only the folder list and correct the column width for double-split views.
  3. Depending on what the navigation stack is currently showing you, extract view controllers from the array and reassemble them into a UISplitViewController.

The method configuredSplit(first:second:) is a helper to create a populated UISplitViewController.

Playing With the Splits

You’re ready to exercise the app, now!

Choose the iPad Pro (9.7-inch) simulator in the Target settings.

Build and run. You can play with a few layout scenarios to see how the trait change works in practice.

Place the app in two-thirds split screen. You can see that the app is still horizontally regular.

2/3 split view

Slide the divider and split the screen to 50/50. The app is now horizontally compact. The active EditorViewController is placed at the top of the navigation stack and a Back button is added to the editor.

equal split

Drag the system control at the top of the app down to convert the app to Slide Over. The editor Back button has been compacted a little.

slide over display

The reverse journey back to horizontally regular will reconstruct the double split. Give it a try!

Where to Go From Here?

You can download the completed version of the project using the Download Materials button at the top or bottom of this tutorial.

You have now learned some basic UISplitViewController mechanics and how to handle the transitions from compact to regular, then back again.

You’ve gone beyond the basics and offered an extra level of hierarchical selection by using a split view embedded in a split view. If your app has a simple hierarchy like TreeWorld, this technique is great for giving users visibility of where they are in their data. However, a note of caution — more levels of hierarchy would make the UI quite confusing, and a single split view with navigation on the left and detail on the right is a safer option.

UISplitViewController has a lot more to offer in terms of free behavior and customization using UISplitViewControllerDelegate. This tutorial has just scratched the surface of possibilities.

Hopefully, this tutorial inspires some great adaptive designs. I look forward to seeing some of your results in the discussion forum below!

Contributors

Comments