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
- Accessing the underlying split view controller can be achieved using Introspect. Add the Swift package
https://github.com/siteline/SwiftUI-Introspect.git. - This provides
introspectNavigationController
which can be given a closure to send messages to and set properties of the navigation controller. - 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 = .allVisible
Code 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 = .tile
Code 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.