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.
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 thePDFView
.
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 View
s 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.