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.

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:).

Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Level up your Swift skills!$35

100+ tips to take your Swift code to the next level

Swift Gemsby Natalia Panferova

  • Advanced Swift techniques for experienced developers bypassing basic tutorials
  • Curated, actionable tips ready for immediate integration into any Swift project
  • Strategies to improve code quality, structure, and performance across all platforms

Level up your Swift skills!

100+ tips to take your Swift code to the next level

Swift Gems by Natalia Panferova book coverSwift Gems by Natalia Panferova book cover

Swift Gems

by Natalia Panferova

$35

# 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 UIViews 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.