Blog

Extending DocumentGroup for iOS

May 26, 2023 swiftuidocument-group

DocumentGroup-based apps make it easy to edit documents and store them on device and in the cloud. However, these apps are currently very limited when it comes to customization. Let’s look at how we can extend them.

DocumentKit logo

Document-based SwiftUI apps use a DocumentGroup scene, into which you can load any custom views to edit various document types. They’re an easy and powerful way to create apps that can edit any kind of documents and store them on the user’s device and in the cloud.

However, document apps are currently very limited when it comes to customizations. For instance, you can’t add custom toolbar items to the document browser, and since a DocumentGroup (unlike regular SwiftUI apps) doesn’t have a view until you open a document, you can’t programmatically present views from the document browser, such as initial onboarding experiences, welcome screens etc.

To fix this for iOS, we currently have to drill down and use the underlying UIKit types. Let’s add a couple of types and extensions to get a SwiftUI approach to handle things like onboarding and customization.

Inspecting the document group

Let’s start with creating a DocumentGroupInspector protocol, that lets us inspect the document group:

public protocol DocumentGroupInspector {}

public extension DocumentGroupInspector {

    var documentBrowser: UIDocumentBrowserViewController? {
        rootViewController as? UIDocumentBrowserViewController
    }

    var rootViewController: UIViewController? {
        keyWindow?.rootViewController
    }

    func dismissCurrentDocument() {
        dismissCurrentDocument {}
    }

    func dismissCurrentDocument(
        completion: @escaping () -> Void
    ) {
        rootViewController?.dismiss(
            animated: true,
            completion: completion
        )
    }
}

private extension DocumentGroupInspector {

    var keyWindow: UIWindow? {
        UIApplication.shared.connectedScenes
            .filter { $0.activationState == .foregroundActive }
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .filter { $0.isKeyWindow }
            .first
    }
}

As you can see, we resolve the current keyWindow and use it to fetch a rootViewController and try to cast it to a documentBrowser. With this in place, we can also provide a way to dismiss documents.

All we have to do now, is to add the protocol to any type, to let it access the document browser:

struct MyView: View, DocumentGroupInspector {

    var body: some View {
        Text("Hello, modal!")
    }
    
    var allowsDocumentCreation: Bool {
        documentBrowser?.allowsDocumentCreation ?? false
    }
}

You can also use the protocol to dismiss the currently presented document:

struct MyButton: View, DocumentGroupInspector {

    var body: some View {
        Button("Dismiss", action: dismissCurrentDocument)
    }
}

Let’s also make DocumentGroup implement the protocol, for some functionality that we soon will add:

extension DocumentGroup: DocumentGroupInspector {}

Important: Note that the DocumentGroup must have been presented for this to work. The internal functionality adds a security delay whenever needed, to ensure that the browser is available.

How to present modal screens

Next, let’s add a DocumentGroupModal protocol, that lets us present any SwiftUI view as a modal sheet or full screen cover, from any DocumentGroup:

public protocol DocumentGroupModal: View, DocumentGroupInspector {}

public extension DocumentGroupModal {

    func presentAsDocumentGroupSheet() throws {
        try presentAsDocumentGroupModal()
    }

    func presentAsDocumentGroupFullScreenCover() throws {
        try presentAsDocumentGroupModal(.fullScreen)
    }

    func presentAsDocumentGroupModal(
        _ presentationStyle: UIModalPresentationStyle = .automatic
    ) throws {
        guard let parent = rootViewController else { throw DocumentGroupError.noParentWindow }
        let controller = UIHostingController(rootView: self)
        controller.modalPresentationStyle = presentationStyle
        parent.present(controller, animated: true, completion: nil)
    }
}

All we have to do is to add the protocol to any view:

struct MyModalView: DocumentGroupModal {

    var body: some View {
        Text("Hello, modal!")
    }
}

We can then present the view as a sheet, full screen cover, or using any UIKit modal presentation style:

MyModalView()
    .presentAsDocumentGroupSheet()
    // .presentAsDocumentGroupFullScreenCover()
    // .presentAsDocumentGroupModal(.overCurrentContext)

This means that any view can now be programmatically presented as a modal from a DocumentGroup.

However, as you may know, SwiftUI makes heavy use of view modifiers, which often return some View. This means that if we apply a to a DocumentGroupModal, it’s no longer of the same type, which means that the extensions will no longer be available.

To solve this, we can add an internal DocumentGroupInspector and make the presentation extensions apply to all views instead:

public protocol DocumentGroupModal: View, DocumentGroupInspector  {

    func presentAsDocumentGroupSheet() throws

    func presentAsDocumentGroupFullScreenCover() throws

    func presentAsDocumentGroupModal(_ style: UIModalPresentationStyle ) throws
}

/// This internal inspector is used by the view extensions.
private class InternalInspector: DocumentGroupInspector {

    static var shared = InternalInspector()
}

public extension View {

    func presentAsDocumentGroupSheet() throws {
        try presentAsDocumentGroupModal(.automatic)
    }

    func presentAsDocumentGroupFullScreenCover() throws {
        try presentAsDocumentGroupModal(.fullScreen)
    }

    func presentAsDocumentGroupModal(_ style: UIModalPresentationStyle) throws {
        let inspector = InternalInspector.shared
        guard let parent = inspector.rootViewController else { throw DocumentGroupError.noParentWindow }
        let controller = UIHostingController(rootView: self)
        controller.modalPresentationStyle = style
        parent.present(controller, animated: true, completion: nil)
    }
}

This may defeat the purpose of having the DocumentGroupModal protocol, but I think it brings clarity to the library. The intended use is still to implement it, but you can now modify a modal and still be able to use these extensions.

How to present an initial onboarding screen

We can now use DocumentGroupModal to easily open an app onboarding whenever a DocumentGroup-based app is launched.

Let’s start with implementing a couple of onboarding-specific DocumentGroup extensions:

public extension DocumentGroup {

    func onboardingSheet<Content: DocumentGroupModal>(
        id: String? = nil,
        store: UserDefaults? = nil,
        delay: TimeInterval? = nil,
        @ViewBuilder content: @escaping () -> Content
    ) -> DocumentGroup {
        onboardingModal(
            id: id,
            store: store,
            presentation: { try $0.presentAsDocumentGroupSheet() },
            content: content
        )
    }

    func onboardingFullScreenCover<Content: DocumentGroupModal>(
        id: String? = nil,
        store: UserDefaults? = nil,
        delay: TimeInterval? = nil,
        @ViewBuilder content: @escaping () -> Content
    ) -> DocumentGroup {
        onboardingModal(
            id: id,
            store: store,
            presentation: { try $0.presentAsDocumentGroupFullScreenCover() },
            content: content
        )
    }
}

private extension DocumentGroup {

    func onboardingModal<Content: DocumentGroupModal>(
        id: String? = nil,
        store: UserDefaults? = nil,
        delay: TimeInterval? = nil,
        presentation: (Content) throws -> Void,
        @ViewBuilder content: @escaping () -> Content
    ) -> DocumentGroup {
        let store = store ?? .standard
        let delay = delay ?? 0.5
        if store.documentGroupOnboardingState(for: id) { return self }
        DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
            do {
                try content().presentAsDocumentGroupFullScreenCover()
                store.setDocumentGroupOnboardingState(to: true, for: id)
            } catch {
                print("*** ERROR: \(error) ***")
            }
        }
        return self
    }
}

As you can see, the extensions let us present any DocumentGroupModal view as an onboarding sheet or cover, with support for a custom onboarding ID, as well as a customizable store and delay.

For this to compile, we also have to add some UserDefaults extensions:

public extension UserDefaults {

    var defaultDocumentGroupOnboardingId: String {
        "com.documentkit.isOnboardingPresented"
    }

    func documentGroupOnboardingState(
        for id: String? = nil
    ) -> Bool {
        bool(forKey: id ?? defaultDocumentGroupOnboardingId)
    }

    func resetDocumentGroupOnboardingState(
        for id: String? = nil
    ) {
        setDocumentGroupOnboardingState(to: false, for: id)
    }

    func setDocumentGroupOnboardingState(
        to value: Bool,
        for id: String? = nil
    ) {
        set(value, forKey: id ?? defaultDocumentGroupOnboardingId)
    }
}

All we have to do now is add onboardingSheet or onboardingFullScreenCover to DocumentGroup:

@main
struct MyApp: App {

    var body: some Scene {
        DocumentGroup(newDocument: MyDocument()) { file in
            ContentView(document: file.$document)
        }.onboardingSheet {
            MyModalView()
        }
    }
}

This will present the onboarding view once, after which it will not be shown again. If you want to present different onboarding views with the same modifier, you can provide a unique id for each onboarding.

If you want to programmatically get and set the presentation state of a certain onboarding, you can use the UserDefaults extensions directly.

How to customize the document browser

Since we have the DocumentGroupInspector protocol and let DocumentGroup implement it to get its underlying document browser, we can finally add extensions to modify the browser.

Since DocumentGroup is a scene and the app must return that scene for the app to build, each modifier must return the DocumentGroup itself.

Let’s first add an action-based function that can be used to modify the underlying document browser:

public extension DocumentGroup {

    typealias DocumentGroupCustomization = (UIDocumentBrowserViewController) -> Void

    func tryCustomizeBrowser(
        delay: TimeInterval = 0.5,
        _ action: @escaping DocumentGroupCustomization,
        tryAgain: Bool = true
    ) -> DocumentGroup {
        if let group = documentBrowser {
            action(group)
        } else {
            DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
                _ = tryCustomizeBrowser(action, tryAgain: false)
            }
        }
        return self
    }
}

We can now use this extension to add more extensions that play well with SwiftUI. For instance, we can add these functions to modify properties of the browser:

public extension DocumentGroup {

    func allowsDocumentCreation(_ value: Bool) -> DocumentGroup {
        tryCustomizeBrowser { $0.allowsDocumentCreation = value }
    }

    func allowsPickingMultipleItems(_ value: Bool) -> DocumentGroup {
        tryCustomizeBrowser { $0.allowsPickingMultipleItems = value }
    }

    func showFileExtensions(_ value: Bool) -> DocumentGroup {
        tryCustomizeBrowser { $0.shouldShowFileExtensions = value }
    }
}

We can also add this type as a convenience type to custom bar button items, to avoid having to use UIKit types in our SwiftUI code:

public class DocumentGroupToolbarItem {

    public init(
        icon: UIImage?,
        action: @escaping () -> Void,
        customization: @escaping (UIBarButtonItem) -> Void = { _ in }
    ) {
        self.icon = icon
        self.action = action
        self.customization = customization
    }

    private let icon: UIImage?
    private let action: () -> Void
    private let customization: (UIBarButtonItem) -> Void

    var barButtonItem: UIBarButtonItem {
        let item = UIBarButtonItem(
            image: icon,
            style: .plain,
            target: self,
            action: #selector(callAction)
        )
        customization(item)
        return item
    }

    @objc
    func callAction() {
        action()
    }
}

We can then add this extension to let us add leading and trailing navigation bar button items:

public extension DocumentGroup {

    func additionalNavigationBarButtonItems(
        leading: [DocumentGroupToolbarItem] = [],
        trailing: [DocumentGroupToolbarItem] = []
    ) -> DocumentGroup {
        let leading = leading.map { $0.barButtonItem }
        let trailing = trailing.map { $0.barButtonItem }
        return tryCustomizeBrowser {
            $0.additionalLeadingNavigationBarButtonItems = leading
            $0.additionalTrailingNavigationBarButtonItems = trailing
        }
    }
}

You can apply these modifiers directly to DocumentGroup. Since they return the document group, you can just keep chaining them together.

Conclusion

With the various types and extensions in place, we can now present modal sheets and covers from the document group, from anywhere in our code. We can also present onboarding screens and modify the underlying document browser with a few simple modifiers:

struct MyApp: App {

    var body: some Scene {
        DocumentGroup(newDocument: DemoDocument()) { file in
            ContentView(document: file.$document)
        }
        .allowsDocumentCreation(false)
        .showFileExtensions(true)
        .additionalNavigationBarButtonItems(
            leading: [...],
            trailing: [....]
        )
        .onboardingSheet {
            MyOnboardingScreen()
        }
    }
}

Let’s keep our fingers crossed that iOS 17 adds many of these features when it launches in a few weeks.

I have added these types and extensions to a new open-source library - DocumentKit. Feel free to try it out and let me know what you think.