George Garside Blog

A place of many ramblings about macOS and development. If you find something useful on here, it's probably an accident.

SwiftUI's NavigationView supports master-detail split view layouts using the DoubleColumnNavigationViewStyle. However, this only applies in landscape on iPadOS.

To imitate a SplitViewController with SwiftUI and tile the master next to the detail in portrait, you can introspect the split view controller that SwiftUI creates underneath the SwiftUI declarations and make the necessary adjustments.

Setup split view introspection

  1. Accessing the underlying split view controller can be achieved using Introspect. Add the Swift package
    https://github.com/siteline/SwiftUI-Introspect.git.
  2. This provides introspectNavigationController which can be given a closure to send messages to and set properties of the navigation controller.
  3. The splitViewController property returns the nearest ancestor in the view controller hierarchy that is a split view controller.
guard let svc = nc.splitViewController else { return }Code language: Swift (swift)

iOS 13 support

iOS 13 uses preferred display mode to determine whether to show the master view side by side with the detail view. Setting this property of the split view controller to the enum value allVisible ensures the master view shows when there's space, including portrait iPad. This doesn't affect portrait iPhone where there's never enough space, so we don't need to handle that case separately.

svc.preferredDisplayMode = .allVisibleCode language: Swift (swift)

There's also a bug we need to work around. The split view controller has a display mode button which hides and shows the sidebar. On iOS 13, tapping this button breaks the UI by trying to hide the sidebar, which we set to ‘all visible’. We can hide the icon by setting the button's view to an empty UIView.

svc.displayModeButtonItem.customView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 0))Code language: Swift (swift)

At this point, let's see our first full example, modifying a sample NavigationView.

NavigationView {
	sidebarList
	mainContent
}
.introspectNavigationController { nc in
	#if os(iOS)
	guard let svc = nc.splitViewController else { return }
	svc.preferredDisplayMode = .allVisible
	svc.displayModeButtonItem.customView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 0))
	#endif
}
Code language: Swift (swift)

iOS 14 support

iOS 14 made lots of changes to the way the split view controller works, adding support for sidebars and up to two master views which can overlay the detail view.

The overlaying of the master view isn't what we want. We can disable that by setting the preferred split behaviour to tile, ensuring both views are shown at the same time and sharing space on the screen.

svc.preferredSplitBehavior = .tileCode language: Swift (swift)

Final solution

We can combine iOS 13 and iOS 14 support into one block using an availability check.

iOS 14 doesn't require the override for the display mode button, and iOS 13 doesn't have the ability to overlay the sidebar on the detail view, so we can if…else inside #available.

NavigationView {
	sidebarList
	mainContent
}
.introspectNavigationController { nc in
	#if os(iOS)
	guard let svc = nc.splitViewController else { return }
	svc.preferredDisplayMode = .allVisible
	if #available(iOS 14.0, *) {
		svc.preferredSplitBehavior = .tile
	} else {
		svc.displayModeButtonItem.customView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 0))
	}
	#endif
}
Code language: Swift (swift)

This is how I get the two column layout in Bluetooth Inspector.

Leave a Reply

No comments