Blog Post

Expanding SwiftUI capabilities in Nutrient: Customizable main toolbar

Stefan Kieleithner
Illustration: Expanding SwiftUI capabilities in Nutrient: Customizable main toolbar

At Nutrient, our mission is to continually enhance the capabilities of our SDKs, making them more powerful and flexible for our customers to use. One of our recent focus areas has been enhancing the SwiftUI capabilities of Nutrient iOS SDK. In particular, we improved our API for customizing the main toolbar.

Handling buttons for toolbars and navigation bars is more complex in SwiftUI if we only provide UIKit APIs, as UINavigationController and UIBarButtonItem don’t interact very well with SwiftUI’s NavigationStack and toolbar modifiers.

In this blog post, I’d like to take you on a journey of investigating and finding the best approach for a flexible public main toolbar API in SwiftUI. The main toolbar, typically shown in the navigation bar on iOS, consists of action buttons for many of the built-in features Nutrient provides — like showing the annotation toolbar, presenting the document outline, and searching the document — while also allowing customers to add their own buttons and change which buttons are visible.

Main Toolbar

The goal: A flexible, customizable toolbar API

PDFView is the view provided by Nutrient iOS SDK for viewing and editing documents in SwiftUI apps. Our primary goal was to develop a SwiftUI API that allows developers to flexibly and intuitively modify the main toolbar shown above or below a PDFView, as we want developers to be able to mix and match their own toolbar buttons with buttons we provide. For Nutrient’s built-in buttons, we want to support customizing the visual appearance of those buttons, while also handling state — such as whether a button is selected — ourselves, so that our customers don’t need to manage this.

We explored a couple possibilities of how an API for this could look, including:

  • Adding special view modifiers to PDFView, which handle adding buttons to the toolbar.

  • Exposing View structs individually for each button, and somehow connecting them with the PDFView.

We really wanted to make use of as much of the system-provided SwiftUI APIs as possible — like the toolbar(content:) modifier to add views, including buttons, to the toolbar — while also allowing developers to add their own buttons to the toolbar easily, so we tried to go with the most flexible approach.

Challenges and initial solutions

We were looking at the latter option of exposing Views for each button individually that customers can then add anywhere they want themselves. This brought up the challenge of how to communicate between the buttons and PDFView while keeping the API surface mostly clean:

var body: some View {
    NavigationStack {
        PDFView(document: ...)
            .toolbar {
                AnnotationButton() // How to communicate from here to `PDFView` without requiring customers to implement this themselves?
            }
    }
}

We came up with making use of the environment in SwiftUI. To make an object available in the environment, we create a new class and then have two options:

  • Make it conform to ObservableObject, so it’s available in the environment using .environmentObject(_:), which we can then use via @EnvironmentObject.

  • Use the new Observation framework, which was introduced at WWDC 2023, mark the class as @Observable, and make it available in the environment using .environment(_:).

Since our SDK supports iOS versions back to iOS 15, we can’t yet make use of the Observation framework. So we created a new type, PDFView.Scope, as an ObservableObject that has all the state for the buttons and allows PDFView to alter this state based on the document shown via @EnvironmentObject:

extension PDFView {
    class Scope: ObservableObject {
        struct ButtonContext {
            var isEnabled: Bool = true
            var isHidden: Bool = false
            var isSelected: Bool = false
        }

        init() { }
        var annotationButtonContext = ButtonContext()
        var thumbnailButtonContext = ButtonContext()
        // ... other button contexts
    }
}
let scope = PDFView.Scope()
var body: some View {
    NavigationStack {
        PDFView(document: ...)
            .toolbar {
                AnnotationButton()
            }
            .environmentObject(scope)
    }
}

To make the API more convenient to use, we added a view modifier API, pdfViewScope(_:), which sets an EnvironmentObject developers can place somewhere in their view hierarchy. This covers both the toolbar buttons and PDFView, so they’re able to communicate using the same environment object:

extension View {
    /// Set a ``PDFView.Scope`` in the view hierarchy.
    /// Any toolbar buttons and ``PDFView`` elements in the child tree
    /// will be able to make use of this ``PDFView.Scope``.
    public func pdfViewScope(_ pdfViewScope: PDFView.Scope) -> some View {
        environmentObject(pdfViewScope)
    }
}

Problem: Unnecessary rerenders

Initially, PDFView.Scope was used as an @EnvironmentObject within PDFView. This approach caused the entire PDFView to rerender whenever any property in PDFView.Scope changed, leading to performance issues and runtime warnings, as we were modifying state while a view was updating. Specifically, the “Publishing changes from within view updates is not allowed” warning indicated a fundamental problem with our state management.

One of the main advantages of @Observable from the Observation framework compared to using ObservableObject is that SwiftUI views only rerender when values change if they’re actually being used in the body of a view. So switching to @Observable would’ve been the perfect solution for us.

Solution: Adopting perception

That’s when we found the Perception library from the folks at Point Free. To address the issues we encountered, we explored using the @Perceptible property wrapper. This wrapper backports the @Observable functionality to earlier iOS versions, back to iOS 13, ensuring that only the views dependent on the modified properties are updated. This selective updating mechanism reduces unnecessary rerenders and improves performance, essentially eliminating the issues we ran into.

We were trying to steer clear of using Swift Package Manager (SPM) to integrate third-party dependencies, as this adds overhead to our build system for our SDK, so we tried to look into alternatives to integrate Perception. Integrating without SPM presented its own set of challenges. Macros in Swift are closely tied to SPM, requiring manual integration steps to use @Perceptible.

We started by checking out the swift-perception repository. After building the binary using swift build -c release, we manually copied the macro binary that was created into our project directory.

Next, we updated the compiler flags to include the macro plugin executable. We added -load-plugin-executable Vendor/swift-perception/PerceptionMacros#PerceptionMacros to Other Swift Flags in the Xcode project build settings. We also copied the necessary source files and adjusted their visibility by replacing public with internal, ensuring they fit seamlessly into our project.

Creating a wrapper for PDFView.Scope

To avoid exposing APIs from the Perception library — like @Perceptible— in our API, we created a wrapper class and a property wrapper for PDFView.Scope. This encapsulation not only protects our API design, but it’ll also allow for a smoother transition to @Observable in the future without breaking the API. Therefore, the implementation of our API ended up looking something like this:

@propertyWrapper public struct Scope: DynamicProperty {
    public class ID {
        var internalPdfContext = InternalPDFContext()
    }
    @State private var value = PDFView.Scope.ID()
    public init() { }
    public var wrappedValue: PDFView.Scope.ID { value }
}

@Perceptible class InternalPDFContext {
    struct ButtonContext {
        var isEnabled: Bool = true
        var isHidden: Bool = false
        var isSelected: Bool = false
    }

    init() { }
    var annotationButtonContext = ButtonContext()
    var thumbnailButtonContext = ButtonContext()
    // ... other button contexts
    let actionEvents = PassthroughSubject<PDFView.ActionEvent, Never>()
}

Creating the new toolbar API

With @Perceptible integrated and the PDFView.Scope encapsulated, we began prototyping the new toolbar API. The focus was on flexibility and ease of use, allowing developers to:

  • Add default SDK buttons — Developers can easily add annotation buttons, thumbnails, document editor, content editor, bookmarks, etc. next to their own custom toolbar buttons

  • Customize visual representation of buttons — Developers can modify how buttons are shown in the toolbar by providing a custom view builder.

This led us to creating the buttons in our SDK that look similar to this:

public struct AnnotationButton<Label: View>: View {
    @Environment(InternalPDFContext.self) private var pdfContext: InternalPDFContext
    private let label: Label

    public init(@ViewBuilder label: () -> Label) {
        self.label = label()
    }

    public var body: some View {
        WithPerceptionTracking {
            if !pdfContext.annotationButtonContext.isHidden {
                Button {
                    let select = !pdfContext.annotationButtonContext.isSelected
                    pdfContext.actionEvents.send(.setAnnotationMode(showAnnotationMode: select, animated: true))
                } label: {
                    label
                }
                .disabled(!pdfContext.annotationButtonContext.isEnabled)
                .keyboardShortcut("a", modifiers: [.command, .shift])
            }
            else {
                EmptyView()
            }
        }
    }
}

This made use of InternalPDFContext in the environment to detect whether a button should be selected, hidden, or disabled. This decision is based on whatever PDFView using that same context in the environment sets based on the visible document.

With all these changes implemented, the usage of our final API showing a PDFView with some of our default buttons and custom buttons could look like this in practice:

@PDFView.Scope var scope
var body: some View {
    NavigationStack {
        PDFView(document: document)
            .toolbar {
                AnnotationButton()
                Button("Custom") { print("Action") }
                OutlineButton()
                ShareButton()
                ThumbnailsButton()
            }
            .pdfViewScope(scope)
    }
}

Conclusion

By exposing a new API for customizing the toolbar, and by making use of the SwiftUI environment and Perception and encapsulating PDFView.Scope, we improved the experience of using SwiftUI with Nutrient. This new approach ensures a smoother developer experience by enabling developers to customize the toolbar in a very SwiftUI-native way. Our customizable toolbar API allows developers to tailor the PDF viewer’s toolbar to their specific needs, changing the visual representation of buttons, and using their own buttons next to Nutrient’s buttons.

Author
Stefan Kieleithner iOS Engineer

Stefan began his journey into iOS development in 2013 and has been passionate about it ever since. In his free time, he enjoys playing board and video games, spending time with his cats, and gardening on his balcony.

Related products
Share post
Free trial Ready to get started?
Free trial