Double column navigation in a SwiftUI document app
While updating one of my apps I have found that on iPadOS there is an annoying limitation of SwiftUI documents based apps. The DocumentGroup implicitly wraps its children in a NavigationView, but it's not possible to switch its style to be DoubleColumnNavigationViewStyle. In this article I will share my solution on how to hide the provided NavigationView
and add my own. We will also see how to recreate the back button that returns the user to the document browser.
Let's start out with a simple ContentView
.
struct ContentView: View {
@Binding var document: SplitViewDocumentAppDocument
var body: some View {
NavigationView {
SideBar(notes: $document.notes)
Label(
"Please Select a Note",
systemImage: "exclamationmark.triangle"
)
}
.navigationViewStyle(
DoubleColumnNavigationViewStyle()
)
}
}
This is the basis of our DoubleColumnNavigationView
, it provides a list of notes in the Sidebar
view and if nothing is selected renders Please Select a Note
in the detail view. However, when used within a DocumentGroup
, the system provided navigation view is also rendered. This gives us an ugly appearance with two navigation bars on screen.
# Hiding the system navigation bar
Adding navigationBarHidden(true) modifier is all that's needed to hide the system provided navigation bar.
struct ContentView: View {
@Binding var document: SplitViewDocumentAppDocument
var body: some View {
NavigationView {
...
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
.navigationBarHidden(true)
}
}
However, doing this does lead to an issue, as our users are no longer able to navigate back to the file browser.
# Navigating back to the document browser
In UIKit the way to navigate back to the DocumentBrowser is to dismiss the UIViewController
that is presented. In SwiftUI we have @Environment(\.presentationMode)
, but in this case calling dismiss()
method does not work.
So it's time for a more hacky solution: inject a UIView
into the view hierarchy so that we can reach up to our parent ViewController
and call dismiss(animated:completion:).
# DismissingView
First, we create a UIViewRepresentable DismissingView
.
struct DismissingView: UIViewRepresentable {
let dismiss: Bool
func makeUIView(context: Context) -> UIView {
let view = UIView()
if dismiss {
DispatchQueue.main.async {
view.dismissViewController()
}
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
if dismiss {
DispatchQueue.main.async {
uiView.dismissViewControler()
}
}
}
}
This will call uiView.dismissViewController()
if/when the dismiss
value is passed to this view. UIView
does not have this method, but we can add it by providing an extension to UIResponder
protocol that searches up the responder chain to find the UIViewController
.
extension UIResponder {
func dismissViewController() {
guard let vc = self as? UIViewController else {
self.next?.dismissViewController()
return
}
vc.dismiss(animated: true)
}
}
Once UIViewController
is found we call dismiss(animated: true)
, dismissing the document view and returning the user to the document browser.
# DismissModifier
The simplest solution would be to set DismissingView
as a background on a toolbar button. When the button is pressed the dismiss value would be set to true
. However, it seems UIView
s embedded in the toolbar do not get fully added to the UIResponder
chain, so we are unable to find the parent UIViewController
.
Instead, it is best to set this view as a background on our top level content, then provide a callable EnvironmentValue
that can be used in our toolbar.
To make this easy to apply, use a ViewModifier
that places DismissingView
as a background and provides a dismiss
callback environment value.
struct DismissModifier: ViewModifier {
@State private var dismiss = false
func body(content: Content) -> some View {
content.background(
DismissingView(dismiss: dismiss)
)
.environment(\.dismiss, {
self.dismiss = true
})
}
}
struct DismissEnvironmentKey: EnvironmentKey {
static var defaultValue: () -> Void { {} }
typealias Value = () -> Void
}
extension EnvironmentValues {
var dismiss: DismissEnvironmentKey.Value {
get {
self[DismissEnvironmentKey.self]
}
set {
self[DismissEnvironmentKey.self] = newValue
}
}
}
The modifier can be applied at the top level of our view hierarchy. Since it depends on UIKit, if you are building a multi-platform app, make sure to use #if canImport(UIKIT)
.
struct ContentView: View {
@Binding var document: SplitViewDocumentAppDocument
var body: some View {
#if canImport(UIKit)
NavigationView {
Sidebar(notes: $document.notes)
Label(
"Please Select a Note",
systemImage: "exclamationmark.triangle"
)
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
.navigationBarHidden(true)
.modifier(DismissModifier())
#else
NavigationView {
Sidebar(notes: $document.notes)
Label(
"Please Select a Note",
systemImage: "exclamationmark.triangle"
)
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
#endif
}
}
# Back button
First we need to create a button that can be used in the toolbar. This button captures dismiss
environment value.
struct DismissDocumentButton: View {
@Environment(\.dismiss) var dismiss
var body: some View {
Button {
dismiss()
} label: {
Label("Close", systemImage: "folder")
}
}
}
To add the button to the toolbar we use toolbar
modifier on List
within the sidebar view.
.toolbar {
#if canImport(UIKit)
ToolbarItem(placement: .cancellationAction) {
DismissDocumentButton()
}
#endif
}
Placement cancellationAction
ensures that the button is placed on the leading edge of the navigation view in iOS and iPadOS.
You can find the full code example on GitHub.