Adding Double Column Navigation to a SwiftUI Document App


While updating Cleora I have found that on iPadOS there is an annoying limitation of SwiftUI documents based apps. Namely the DocumentGroup implicitly wraps its children in a NavigationView however it is not possible to switch its NavigationViewStyle to DoubleColumnNavigationViewStyle. In this article I will share my solution to hiding the provided NavigationView and providing my own including how to recreate a back button that returns the user to the DocumentBrowser.


Let us 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 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 situation with 2 navigation bars on screen.


Hiding the system provided navigation bar


Adding .navigationBarHidden(true) is all that is 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.



In UIKit the way to navigate back to the DocumentBrowser it to dismiss the UIViewController that is presented. In SwiftUI we do have @Environment(.presentationMode) however unfortunately in this case calling .dismiss() does not work. So it is time for a more wacky solution: by injection of a UIView into the view hierarchy so that we can reach up to our parent ViewController and call dismiss(animated:completion:).


To do this we will make use of the UIViewRepresentable:


struct DismissingView: UIViewRepresentable {
        
    let dismiss: Bool
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        if dismiss {
            DispatchQueue.main.async {
                view.dismissViewControler()
            }
        }
        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 the 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 the UIViewController is found we call .dismiss(animated: true) dismissing the document view and returning the user to the document browser.


Using the DismissingView


The simplest solution would be to set this as a background on a toolbar button and then when the button is pressed the dismiss value would be set to true. However it seems UIViews embedded in the toolbar do not get fully added to the UIResponder chain so we are unable to find the parent UIViewController.


So instead it is best to set this view as a background on our top level content, then provider a callable EnvironmentValue that can be used in our toolbar.


Wrapping up the DismissingView


To make this easy to apply use a ViewModifier that places the DismissingView view as a background and then provides a dismiss callback EnvironmentValue.


struct DismissModifier: ViewModifier {
    @State
    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
        }
    }
}


This can be applied at the top level of our view hierarchy. By adding .modifier(DismissModifier()). Since this depends on UIKit if you are building a multi-platform app make use of #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
    }
}


Using the dismiss EnvironmentValue

First we need to create a button that can be used in the toolbar.

struct DismissDocumentButton: View {
    
    @Environment(\.dismiss)
    var dismiss
    
    var body: some View {
        Button {
            dismiss()
        } label: {
            Label("Close", systemImage: "folder")
        }
    }
}


This button captures the dismiss EnvironmentValue. To add this to the toolbar use .toolbar on List within the Sidebar view.


.toolbar {
    #if canImport(UIKit)
        ToolbarItem(placement: .cancellationAction) {
            DismissDocumentButton()
        }
    #endif
}


This will add our back button toolbar, the .cancellationAction ensures it is placed on the leading edge of the navigation view (in iOS/iPadOS).


You can find the full code example here.