Introduction to UISplitViewController
The UISplitViewController is a crucial component in the UIKit framework for building adaptive user interfaces, particularly for applications targeting a wide range of iOS devices. Its primary role is to present one or more content view controllers side-by-side when enough screen space is available, typically on iPads or large iPhones in landscape orientation. When space is constrained, such as on iPhones in portrait or compact environments, it gracefully adapts its presentation, often collapsing into a single-column navigation stack.
Historically, UISplitViewController was the foundation for iconic iPad apps like Mail and Settings, providing a clear master-detail workflow. With iOS 14, Apple introduced UISplitViewController.Style, significantly modernizing its API and making it even more flexible for displaying multiple columns of content, not just two. This allows you to create highly dynamic and adaptive layouts that feel natural across all iOS device sizes, including the increasing variety of iPhone screen sizes and the traditional iPad experience. Understanding UISplitViewController is key to delivering a first-class user experience that leverages the full capabilities of each device.
Basic Setup: Two-Column vs. Three-Column Styles
UISplitViewController offers distinct presentation styles to accommodate different app requirements. The most common styles are split (for a traditional two-column master-detail) and tripleColumn (for a more complex three-column layout).
To initialize a UISplitViewController, you typically provide its child view controllers. For a two-column setup, you'll need a primary (master) and a secondary (detail) view controller. For a three-column setup, you'll add a compact/tertiary view controller. The order matters: the first view controller is the primary, the second is the secondary, and the third (if present) is the compact/tertiary.
Let's look at how to set up a basic two-column split view controller. This example sets up a primary 'Menu' view controller and a secondary 'Content' view controller. On iPads, they'll appear side-by-side. On iPhones, the primary will typically appear first, and selecting an item will push the secondary onto the navigation stack.
import UIKit
class MasterViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGray5
title = "Master"
let label = UILabel()
label.text = "Select an item"
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
}
class DetailViewController: UIViewController {
var itemText: String? {
didSet {
updateUI()
}
}
private let label = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Detail"
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
updateUI()
}
private func updateUI() {
label.text = itemText ?? "No Item Selected"
}
}
func setupTwoColumnSplitView() -> UISplitViewController {
let masterVC = MasterViewController()
let detailVC = DetailViewController()
let primaryNavController = UINavigationController(rootViewController: masterVC)
let secondaryNavController = UINavigationController(rootViewController: detailVC)
let splitVC = UISplitViewController(style: .split)
splitVC.viewControllers = [primaryNavController, secondaryNavController]
// Recommended for managing collapse behavior
splitVC.setViewController(primaryNavController, for: .primary)
splitVC.setViewController(secondaryNavController, for: .secondary)
// Customize display mode (e.g., show both columns by default on iPad)
splitVC.preferredDisplayMode = .automatic
return splitVC
}
// Example usage in your SceneDelegate or AppDelegate (iOS 13+):
/*
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = setupTwoColumnSplitView()
self.window = window
window.makeKeyAndVisible()
}
*/
Understanding Display Modes and Preferred Column Widths
UISplitViewController offers several preferredDisplayMode options to control how its columns are presented. These modes dictate whether the primary column is always visible, hidden by default, or behaves automatically based on device orientation and size class.
.automatic: The split view controller automatically chooses the best display mode based on the environment.
.secondaryOnly: Only the secondary (detail) column is visible. The primary can be revealed by a swipe gesture or a bar button item.
.oneOverSecondary: The primary column is presented over the secondary column, obscuring it. This is common in compact environments where the primary acts as a menu.
.twoOverSecondary: Similar to .oneOverSecondary, but for three-column layouts, the compact column slides over the secondary.
.twoBesideSecondary: On three-column layouts, the compact column automatically appears alongside the secondary when space permits.
You can also influence the width of the columns using properties like preferredPrimaryColumnWidthFraction, preferredPrimaryColumnWidth, preferredSupplementaryColumnWidthFraction, and preferredSupplementaryColumnWidth. These properties allow you to fine-tune the layout to fit your app's content perfectly. Remember that these are preferred values, and the system might adjust them based on available space.
Setting a preferredPrimaryColumnWidthFraction of 0.3 means the primary column will attempt to occupy 30% of the split view controller's total width when in a side-by-side display mode.
func setupCustomSplitView() -> UISplitViewController {
let masterVC = MasterViewController()
let detailVC = DetailViewController()
let primaryNavController = UINavigationController(rootViewController: masterVC)
let secondaryNavController = UINavigationController(rootViewController: detailVC)
let splitVC = UISplitViewController(style: .split)
splitVC.viewControllers = [primaryNavController, secondaryNavController]
// Set a specific preferred display mode
splitVC.preferredDisplayMode = .twoBesideSecondary
// Set a preferred width for the primary column
splitVC.preferredPrimaryColumnWidthFraction = 0.3 // Primary column takes 30% width
splitVC.minimumPrimaryColumnWidth = 320 // Ensure primary column is at least 320 points wide
splitVC.maximumPrimaryColumnWidth = 500 // Cap the primary column width at 500 points
// This ensures that the detail view fills the space if only one VC is shown
splitVC.presentsWithGesture = true // Allow swipe gesture to reveal primary
return splitVC
}
Handling Collapse and Expand Behavior (Delegates)
When UISplitViewController transitions between showing multiple columns and a single column (e.g., rotating an iPad from landscape to portrait, or running on an iPhone), it's said to collapse or expand. You can gain fine-grained control over this behavior using the UISplitViewControllerDelegate protocol.
The most important delegate method is splitViewController(_:collapseSecondary:onto:). This method allows you to specify what happens to the secondary (detail) view controller when the split view collapses. By default, the secondary view controller is often pushed onto the navigation stack of the primary. You can customize this, for example, by discarding the secondary view controller or merging its content with the primary.
Another key method is splitViewController(_:separateSecondaryFrom:), which is called when the split view controller expands from a collapsed state. This is where you can recreate or properly configure your secondary view controller if it was discarded or modified during the collapse.
These delegate methods are crucial for maintaining state and ensuring a seamless user experience during device rotation and size class changes. For modern iOS development (iOS 14+), the default behavior of UISplitViewController is generally good, but the delegate provides escape hatches for complex scenarios.
import UIKit
class CustomSplitViewController: UISplitViewController, UISplitViewControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
self.preferredDisplayMode = .automatic
let masterVC = MasterViewController()
let detailVC = DetailViewController()
let primaryNavController = UINavigationController(rootViewController: masterVC)
let secondaryNavController = UINavigationController(rootViewController: detailVC)
setViewController(primaryNavController, for: .primary)
setViewController(secondaryNavController, for: .secondary)
}
// MARK: - UISplitViewControllerDelegate
// This method is called when the split view controller is about to collapse.
// Return true if you want the secondary view controller to be collapsed onto the primary.
// Return false if you want to handle the collapse manually (e.g., discard secondary).
// For iOS 14+, the default behavior is often sufficient.
func splitViewController(
_ splitViewController: UISplitViewController,
collapseSecondary secondaryViewController: UIViewController,
onto primaryViewController: UIViewController
) -> Bool {
guard let secondaryNavController = secondaryViewController as? UINavigationController,
let detailVC = secondaryNavController.topViewController as? DetailViewController else {
return true // Let the system handle it if types don't match
}
// If the detail view controller isn't displaying unique content,
// we might not want to show it when collapsing.
if detailVC.itemText == nil {
// Prevent the detail view from being pushed onto the primary's nav stack
return true // Returning true means it will collapse onto primary (pushing detailVC if needed)
// Or you can try to remove it if you don't want it pushed
}
return false // If you return false, you MUST handle the collapse yourself,
// e.g., by pushing the detailVC onto the primary's nav stack MANUALLY
// or by simply returning control to the system to display primary only.
}
// This method is called when the split view controller is about to expand.
// Use this to ensure the secondary view controller is correctly presented/setup.
func splitViewController(
_ splitViewController: UISplitViewController,
separateSecondaryFrom primaryViewController: UIViewController
) -> UIViewController? {
if let secondaryVC = splitViewController.viewController(for: .secondary) {
return secondaryVC
} else {
// If secondary was discarded, recreate it here
let newDetailVC = DetailViewController()
let newSecondaryNavController = UINavigationController(rootViewController: newDetailVC)
return newSecondaryNavController
}
}
}
Integrating with Navigation and Toolbar Items
When UISplitViewController collapses, it usually hides the primary column and only displays the secondary. To provide a way for users to access the primary column (e.g., a list of items), UISplitViewController automatically provides a displayModeButtonItem to the secondary's navigationItem.
You should attach this button item to your secondary view controller's navigation bar. This button, often appearing as a hamburger icon or back arrow, will toggle the primary column's visibility when the split view is in a collapsed state. This built-in functionality simplifies adapting your UI for different size classes.
For UISplitViewController with .split or .tripleColumn styles on iOS 14+, the displayModeButtonItem is more intelligently managed by the system. If you implement a UINavigationController as your secondary view controller, the split view controller takes care of inserting the button when necessary. If you're using a custom view controller arrangement, you might need to manually observe displayModeButtonItem changes or ensure your secondary view controller has a navigationItem where the item can be placed.
import UIKit
class DetailViewControllerWithMenuButton: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Detail"
// Ensure a navigation item exists to hold the button
if let splitVC = splitViewController {
// This button automatically toggles the primary column visibility
// when the split view controller is collapsed.
navigationItem.leftBarButtonItem = splitVC.displayModeButtonItem
navigationItem.leftItemsSupplementBackButton = true
}
}
}
// To use this, replace DetailViewController with DetailViewControllerWithMenuButton
// in your split view setup.
func setupSplitViewWithToggle() -> UISplitViewController {
let masterVC = MasterViewController()
let detailVC = DetailViewControllerWithMenuButton()
let primaryNavController = UINavigationController(rootViewController: masterVC)
let secondaryNavController = UINavigationController(rootViewController: detailVC)
let splitVC = UISplitViewController(style: .split)
splitVC.viewControllers = [primaryNavController, secondaryNavController]
splitVC.preferredDisplayMode = .automatic
splitVC.delegate = splitVC.self as? UISplitViewControllerDelegate
return splitVC
}
Advanced: Programmatic Display Mode Changes
While preferredDisplayMode and the displayModeButtonItem handle most scenarios, sometimes you might need to programmatically control the display mode. For instance, after a user selects an item in the primary column, you might want the split view to automatically hide the primary and show only the detail on an iPad, effectively mimicking the iPhone's navigation behavior.
You can achieve this by setting the displayMode property directly. For example, if you want to ensure only the secondary column is visible after a selection, you can set splitViewController.displayMode = .secondaryOnly. This is particularly useful in master-detail flows where the user has made a definite choice and the master list is no longer immediately needed.
It's important to understand the hierarchy of control: preferredDisplayMode is a preference, while displayMode is the current, active display state. Setting displayMode directly overrides preferredDisplayMode until the next collapse/expand event or until preferredDisplayMode takes effect again. Always ensure these programmatic changes enhance the user experience rather than confusing it.
import UIKit
class MasterViewControllerWithSelection: UIViewController {
weak var detailVC: DetailViewController? // Keep a weak reference to the detail
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGray5
title = "Master List"
let showDetailButton = UIButton(type: .system)
showDetailButton.setTitle("Show Item 1 Detail", for: .normal)
showDetailButton.addTarget(self, action: #selector(showDetail), for: .touchUpInside)
showDetailButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(showDetailButton)
NSLayoutConstraint.activate([
showDetailButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
showDetailButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
}
@objc func showDetail() {
detailVC?.itemText = "Item 1 Selected!"
// If we are currently collapsed (e.g., on iPhone or iPad portrait),
// the split view controller will handle pushing the detail.
// If we are expanded (iPad landscape), we can optionally hide the primary.
if let splitVC = splitViewController {
// programmatically hide the primary pane after selection on wider screens
// This is especially useful for three-column layouts where you want to
// focus on the 'tertiary' column after selection.
if splitVC.displayMode == .oneBesideSecondary || splitVC.displayMode == .twoBesideSecondary {
splitVC.hide(.primary)
}
}
// On compact environments, detailVC might be pushed. Ensure it's visible.
if let nav = detailVC?.navigationController, nav.topViewController != detailVC {
nav.pushViewController(detailVC!, animated: true)
}
}
}
// For this to work, you'd integrate MasterViewControllerWithSelection
// and pass a reference to its detailVC:
func setupSplitViewWithProgrammaticDisplay() -> UISplitViewController {
let masterVC = MasterViewControllerWithSelection()
let detailVC = DetailViewController()
masterVC.detailVC = detailVC // Pass reference
let primaryNavController = UINavigationController(rootViewController: masterVC)
let secondaryNavController = UINavigationController(rootViewController: detailVC)
let splitVC = UISplitViewController(style: .split)
splitVC.setViewController(primaryNavController, for: .primary)
splitVC.setViewController(secondaryNavController, for: .secondary)
// iOS 14+ specific way to assign view controllers correctly
splitVC.setViewController(primaryNavController, for: .primary)
splitVC.setViewController(secondaryNavController, for: .secondary)
splitVC.preferredDisplayMode = .automatic
return splitVC
}