Blog Post

SwiftUI In Production

Illustration: SwiftUI In Production

As of the 10.3 release of our iOS PDF SDK, we now ship SwiftUI in production. This is a big milestone for us, and more so an interesting one, as our product still supports iOS 12. In this post, we want to explain both how we built our Electronic Signatures component while also keeping things working for iOS 12, and why we picked SwiftUI for it. If you prefer to watch the presentation, click here.

The New Feature

For this release, we had the goal of improving the signing flow in PDF documents. Signatures are an important use case, and we’ve been supporting signing for years. But now you can sign not only via drawing your signature, but also via scanning an existing signature or typing your name — a flow common in most signature solutions.

We plan to drop iOS 12 later this summer when we start working on iOS 15 compatibility. Therefore, we decided it was reasonable for our new signing feature to require iOS 13. However, if our customers must support iOS 12 as a hard requirement, they can continue using our older drawing signature flow.

Benefits of SwiftUI

SwiftUI is an attractive technology because it simplifies the flow of updating views. We don’t allow Interface Builder or Storyboards in our codebase, since merging conflicts in these files is a nightmare. All our UI is built in code, which is quite verbose with UIKit. SwiftUI makes this much faster — after the initial learning curve.

Another benefit is that it can be mixed with existing code. Our draw view is highly optimized, with features such as natural drawing and predictive touches to make drawing with Apple Pencil on modern 120 Hz iPads a magical experience. This currently can’t be built in “pure” SwiftUI; however, we can easily reuse this class, which is still written in Objective-C.

The State of SwiftUI

In this post, we’ll refer to SwiftUI for iOS 13 as SwiftUI 1 and SwiftUI for iOS 14 as SwiftUI 2. Because SwiftUI is a relatively new technology, it isn’t without its problems. Even so, SwiftUI 1 is already usable, and Apple went on to make some great improvements in v2.

That said, we found that SwiftUI previews don’t work for us. They work fine in small projects, but in our project, they frequently time out or just show random errors. That’s an area we hope Apple will improve, because previews are very convenient to iterate, if they work.

We avoid many problems by encapsulating views in UIHostingController. Presentation is handled by UIKit, and SwiftUI is fairly contained in this component.

Mixing with Existing Code

The signature creation flow is managed by SignatureAnnotationCreator, which includes a factory method that sets up the view controller and adds the resulting annotation into the PDF. There’s no reason why this file can’t be Swift; we just haven’t had a need to convert it yet.

In the new component, we use a NavigationView as the base, so the main presentation difference is if the controller needs to be wrapped or if we can present it as is:

- (nullable UIViewController *)presentSignatureCreationController:(nullable id)sender {
    let configuration = self.configuration;

    UIViewController *signatureController;
    BOOL wrapInNavigationController;

    switch (PSPDFGetSignatureFeatureAvailability()) {
        case PSPDFSignatureFeatureAvailabilityNone: {
            return nil;
        }
        case PSPDFSignatureFeatureAvailabilityLegacySignatures: {
            let signatureVC = PSPDFClassCreate(PSPDFSignatureViewController);
            signatureVC.naturalDrawingEnabled = configuration.naturalSignatureDrawingEnabled;
            signatureVC.delegate = self;
            signatureController = signatureVC;
            wrapInNavigationController = YES;
            break;
        }
        case PSPDFSignatureFeatureAvailabilityElectronicSignatures: {
            if (@available(iOS 13.0, *)) {
                let signatureVC = PSPDFClassCreate(PSPDFSignatureCreationViewController);
                signatureVC.configuration = configuration.signatureCreationConfiguration;
                signatureVC.delegate = self;
                wrapInNavigationController = NO;
            } else {
                PSPDFLogWarning(@"Electronic Signatures is not supported on iOS 12.");
                return nil;
            }
            break;
        }
    }
    // Present the controller.

The new PSPDFSignatureCreationViewController is available in both Swift and Objective-C, as our product is used in many large apps, and a lot of them still use Objective-C. Our customers neither know that they’re using SwiftUI, nor do they need to care. It just works.

Challenges

Supporting SwiftUI 1 in particular has had its challenges. Often when people write “this cannot be done in SwiftUI,” they’re both correct and incorrect — the beauty of SwiftUI is that we can freely mix it with UIKit (or AppKit on the Mac) and add missing functionality ourselves, or work around bugs.

Adding hacks specifically for SwiftUI 1 is also fairly safe. The OS already shipped, and even if you apply “dirty” hacks such as view introspection, it will work reliably, as it’s no longer changing. Shipping such bugs for iOS 14+ is riskier, and especially so with iOS 15, as SwiftUI will again make a great leap and change many internals.

That said, let’s explore some of the workarounds we used to polish the Electronic Signatures component.

The following source code snippets are simplified for clarity. @available(iOS 13.0, *) is omitted, and various configuration calls that aren’t needed to understand the examples have been removed.

Presenting Popovers on iPhone

In SwiftUI, popovers are always presented as sheets in compact environments. With adaptivePresentationStyle, it’s possible to show popovers even on iPhone via returning .none in the delegate.

iPhone in landscape mode showing the new Electronic Signature feature

It’s possible to rebuild popovers in SwiftUI, but getting all the details right isn’t trivial, and it just seems like more work than using the existing UI framework logic:

AnchorButton { view in
    if isShowingPopover {
        isShowingPopover = false
    } else {
        let fontList = FontListController(model: model)
        fontList.present(on: view)
        isShowingPopover = true
        fontList.addWillDismissAction {
            isShowingPopover = false
        }
    }
} content: {
    Circle()
        .strokeBorder(Color(.lightGray), lineWidth: 1)
        .overlay(Image(uiImage: SDK.imageNamed("font_list"))
        .frame(width: 40, height: 40)
}

To mix/match these two worlds, we need a few tricks. We wrote about a solution in Presenting Popovers from SwiftUI, where we open sourced AnchorButton. Back then, our use case was presenting existing view controllers (such as OutlineViewController) to show the document table of contents. The same principle can be used to present a controller on any view, and the code didn’t even need to be modified.

The FontListController offers a convenience method to present itself on a view:

func present(on sender: UIView) {
    modalPresentationStyle = .popover
    preferredContentSize = CGSize(width: 350, height: 200)

    let popover = popoverPresentationController!
    popover.sourceView = sender
    popover.sourceRect = sender.bounds
    popover.delegate = self
    popover.permittedArrowDirections = .down

    let sourceVC = sender.pspdf_closestViewController
    sourceVC?.present(self, animated: true, completion: nil)
}

Since we need a view controller to present, the sender.pspdf_closestViewController helper crawls up the responder chain (via nextResponder) to find the nearest view controller. The responder chain is documented to include views and view controllers, so this is safe to do.

The remaining logic is in viewDidLoad, where we add the SignatureFontList SwiftUI view via a hosting controller. We use a helper to set up the auto layout constraints and child view relationship.

To dismiss the view, we pass back a block that calls dismiss on this container view controller. This is again an optimization so that we can allow touches on the signature controller without having every touch outside dismiss the popover automatically:

public override func viewDidLoad() {
    super.viewDidLoad()

    let fontList = SignatureFontList(model: model, compactStyle: true) {
        self.dismiss(animated: true)
    }

    let hostingVC = UIHostingController(rootView: fontList)
    add(childViewController: hostingVC)
}

When there’s enough space, we don’t need a popover, and we use SignatureFontList in SwiftUI directly.

iPad in portrait mode

This works great, and we can easily generalize this code to a generic popover container if we have this need in other places in our codebase in the future. We apply YAGNI here and refactor once we need it.

Another benefit is that we’re working around a bug in which having multiple different .popover modifiers doesn’t work until iOS 14.5.

Toolbars in SwiftUI

SwiftUI 2 introduces the .toolbar modifier, which improves how toolbar items can be added. The new API gets details right, e.g. marking the Done confirmation action bold to match the UIKit experience.

On iOS 13, there’s navigationBarItems(leading:trailing:); however, there’s no simple API to center elements in the toolbar. We use the amazing SwiftUIX as a temporary bridge to get similar functionality via navigationBarItems(leading:center:trailing:). We’ll drop this code once we can drop iOS 13, so as to keep the simpler, more modern solution:

// The toolbar API improved significantly, leading to a better result on iOS 14.
if #available(iOS 14.0, *) {
    NavigationView {
        main
            .toolbar {
                ToolbarItem(placement: .confirmationAction) { doneButton }
                ToolbarItem(placement: .cancellationAction) { cancelButton }
                ToolbarItem(placement: .principal) { HeaderView(showPicker: showPickerInNavigationBar, model: model) }
            }
            .navigationBarTitleDisplayMode(.inline)
            .navigationTitle("Add Signature")
    }
    .navigationViewStyle(StackNavigationViewStyle())
} else {
    NavigationView {
        main
            // Supporting the toolbar on iOS 13 relies on SwiftUIX extensions.
            .navigationBarItems(leading: cancelButton, center: HeaderView(showPicker: showPickerInNavigationBar, model: model), trailing: doneButton.font(.headline), displayMode: .inline)
            .navigationBarTitle(Text("Add Signature"), displayMode: .inline)
    }
    .navigationViewStyle(StackNavigationViewStyle())
}

In many cases, this could be simplified using a conditional view modifier, but since this API isn’t available on iOS 13, we still need to use #available, which doesn’t work directly:

/// Apply a view modifier conditionally.
@ViewBuilder func applyIf<T: View>(_ condition: @autoclosure () -> Bool, apply: (Self) -> T) -> some View {
    if condition() {
        apply(self)
    } else {
        self
    }
}

Once SE-0308: #if for postfix member expressions is implemented, we can simplify this code even more.

Geometry Readers

To test the component, we first shipped it in our free PDF Viewer app. We closely monitor crash rates there, and we saw an “AttributeGraph precondition failure: invalid input index: 2.” that only happens in our Mac Catalyst version when running on macOS Catalina:

Exception Type:        EXC_CRASH (SIGABRT)
Exception Codes:       0x0000000000000000, 0x0000000000000000
Exception Note:        EXC_CORPSE_NOTIFY

Application Specific Information:
abort() called
AttributeGraph precondition failure: invalid input index: 2.

Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0   libsystem_kernel.dylib        	0x00007fff677a733a __pthread_kill + 10
1   libsystem_pthread.dylib       	0x00007fff67867e60 pthread_kill + 430
2   libsystem_c.dylib             	0x00007fff6772e808 abort + 120
3   com.apple.AttributeGraph      	0x00007fff41832631 AG::precondition_failure(char const*, ...) + 273
4   com.apple.AttributeGraph      	0x00007fff418096a6 AG::Graph::input_value_ref_slow(unsigned int, unsigned int, AGTypeID, bool*) + 490
5   com.apple.SwiftUI             	0x00007fff6a3a0761 RootGeometry.update(context:) + 97
6   com.apple.SwiftUI             	0x00007fff6a3a44d8 partial apply for protocol witness for static UntypedAttribute._update(_:graph:attribute:) in conformance RootGeometry + 24
7   com.apple.AttributeGraph      	0x00007fff41805bb9 AG::Graph::UpdateStack::update() + 455

Geometry readers have been a source of bugs for a long time. However, most problems were fixed in iOS 14.2. Since the signature UI is highly adaptive, we didn’t manage to build it without GeometryReader. However, we had one case where we had nested readers:

var body: some View {
    GeometryReader { allMetrics in
        VStack(spacing: 0) {
            GeometryReader { metrics in

This pattern is a code smell, and usually it’s possible to modify this to need, at most, one GeometryReader. We just didn’t know better at the time. When you encounter a crash, look for a GeometryReader that’s not at the top level and consider simplifying your views. Since our crash was exclusively a problem for Catalyst, and since there are no compact size classes here, we don’t call the inner metrics callback and assume enough size to not switch to a small presentation.

ObservableObject on iOS 13

Since we still support Objective-C, we offer access into one of the configuration classes that also serves as ObservableObject for SwiftUI. This combination is buggy on iOS 13, and @Published doesn’t send change events for objects that inherit from NSObject.

We’ve been working around this issue by manually defining and calling objectWillChange. This workaround can be removed once we drop iOS 13:

@objc(PSPDFSignatureViewModel)
class SignatureViewModel: NSObject, ObservableObject {

    let objectWillChange = PassthroughSubject<Void, Never>()
    private func sendChangeEvent() {
        self.objectWillChange.send()
    }

    // input
    @Published @objc var typedSignature = "" {
        willSet { sendChangeEvent() }
    }

This can also be solved by using a separate class for the Objective-C compatibility layer that forwards to this model; however, the above approach is less boilerplate and ultimately more elegant.

First Responder

When the Type mode is tapped, we want to automatically show the software keyboard. To date, there’s no way to do this in “pure” SwiftUI.

However, this is easy to do in UIKit: We use a custom view modifier to add this functionality. As an added benefit, getting access to the internally used UITextView also enables us to set a custom returnKeyType, which is also missing from SwiftUI:

textField(with: $model.typedSignature)
	.foregroundColor(Color(model.selectedColor))
	.safeMinimumScaleFactor(0.5)
	.customizeTextFieldOnAppear { textField in
	    textField.returnKeyType = .done
	    // Show the keyboard when this tab appears if no text was entered before.
	    if model.typedSignature.isEmpty {
	        textField.becomeFirstResponder()
	    }
	}

extension View {
    public func customizeTextFieldOnAppear(customizeBlock: @escaping (UITextField) -> Void) -> some View {
        return overlay(TextFieldFinder(customizeBlock: customizeBlock).frame(width: 0, height: 0))
    }
}

The TextFieldFinder helper is a simple UIView subclass that overrides didMoveToWindow to find the UITextField that’s added by SwiftUI.

This introspection pattern is something we see as absolutely necessary to polish SwiftUI. The popular SwiftUI-Introspect library implements this feature via introspectTextField and offers access to many more controls.

Now, this is a hack and not something that’s documented to work like the responder chain trick we explained earlier. We recommend using this only if your views still work once the introspecting functionality breaks — or to retrofit features that are missing from an earlier version of SwiftUI. In our case, all critical functionality would still be available if Apple were to switch to a different internal-only control.

If you require access to a UIKit control for a critical feature, wrap the UIKit component yourself. You can guarantee that SwiftUI isn’t changing the implementation underneath you, and wrapping is quite easy; after all it’s the same control that SwiftUI uses.

Scale Factor Loop

We noticed another bug in SwiftUI 1, where setting a value other than 1 on minimumScaleFactor(_:) would cause an endless layout loop after deleting the last character on TextField. We simply don’t scale in this case, and we use a custom view extension, safeMinimumScaleFactor(_:), to generalize this workaround:

extension View {
    func safeMinimumScaleFactor(_ scaleFactor: CGFloat) -> some View {
        let factor: CGFloat
        if #available(iOS 14.0, *) {
            factor = scaleFactor
        } else {
            // Using `minimumScaleFactor(_:)` with a value other than 1 causes a layout issue,
            // triggering a main thread freeze when deleting the last character on iOS 13.
            factor = 1
        }
        return self.minimumScaleFactor(factor)
    }
}

Wrapping Views

While it’s relatively simple to embed UIKit views in SwiftUI, this can be made even simpler with the following wrapper view:

struct WrapView<Wrapped: UIView>: UIViewRepresentable {
    typealias Updater = (Wrapped, Context) -> Void

    var makeView: () -> Wrapped
    var update: (Wrapped, Context) -> Void

    init(_ makeView: @escaping @autoclosure () -> Wrapped,
         updater update: @escaping Updater) {
        self.makeView = makeView
        self.update = update
    }

    func makeUIView(context: Context) -> Wrapped {
        makeView()
    }

    func updateUIView(_ view: Wrapped, context: Context) {
        update(view, context)
    }
}

extension WrapView {
    init(_ makeView: @escaping @autoclosure () -> Wrapped,
         updater update: @escaping (Wrapped) -> Void) {
        self.makeView = makeView
        self.update = { view, _ in update(view) }
    }

    init(_ makeView: @escaping @autoclosure () -> Wrapped) {
        self.makeView = makeView
        self.update = { _, _ in }
    }
}

And when using the wrapper, adding and configuring views becomes incredibly easy:

WrapView(setupDrawView()) { drawView in
    drawView.strokeColor = model.selectedColor
}

Here, changing selectedColor automatically rebuilds the SwiftUI view graph, and we get all the benefits of SwiftUI’s performant view diffing, even for our existing UIKit views.

Retrofitting Combine Publisher

We modify the UI after drawing a signature. To do that, we use a publisher and bind the result to an isValid Boolean on the model object.

var isDrawingValidPublisher: AnyPublisher<Bool, Never> {
        drawView.pointSequencesPublisher
            .removeDuplicates()
            .map { allPointSequences in
                allPointSequences.contains {
                    $0.count > 1
                }
            }
            .eraseToAnyPublisher()
    }

// Somewhere later.
isDrawingValidPublisher
    .receive(on: RunLoop.main)
    .assign(to: \.isValid, on: self)
    .store(in: &cancellables)

Since our DrawView was written before Combine existed, there was no existing publisher. It’s also written in Objective-C++ (for performance), but we can use an extension to add a publisher to it. Since we already post notifications for these changes, we map these notifications into a publisher:

public extension DrawView {
    /// This publisher fires whenever the `drawView`'s `pointSequences` or `drawLayers` change.
    var pointSequencesPublisher: AnyPublisher<[[DrawingPoint]], Never> {
        let pointSequencesChanged = NotificationCenter.default.publisher(for: .DrawViewDidChangePointSequences, object: self)
            .map { _ in self.pointSequences }
        let drawLayersChangedPublisher = publisher(for: \.drawLayers)
            .map { _ in self.pointSequences }
        return Publishers.Merge(pointSequencesChanged, drawLayersChangedPublisher)
            .eraseToAnyPublisher()
    }
}

Keyboard Avoidance

SwiftUI 2 has great automatic keyboard avoidance built in. This feature doesn’t exist in SwiftUI 1, so we had to retrofit it ourselves:

struct KeyboardView: UIViewRepresentable {
    @Binding var intersectingKeyboardHeight: CGFloat

    typealias UIViewType = UIView

    func makeUIView(context: Self.Context) -> Self.UIViewType {
        UIView().then {
            $0.setContentCompressionResistancePriority(.required, for: .vertical)
            $0.setContentHuggingPriority(.required, for: .vertical)
            context.coordinator.uiview = $0
        }
    }

    func updateUIView(_ uiView: Self.UIViewType, context: Self.Context) { }

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

KeyboardView wraps a UIView, but the actual logic happens in the coordinator. We listen to UIResponder.keyboardWillChangeFrameNotification, get the keyboard rect, convert it to the hosting controller, and calculate the intersection height:

class Coordinator: NSObject {
    var keyboardView: KeyboardView
    var uiview: UIView?

    init(_ keyboardView: KeyboardView) {
        self.keyboardView = keyboardView
        super.init()
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardChange), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
    }

    @objc func keyboardChange(notification: Notification) {
        if let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
            let keyboardAnimationDuration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double) ?? 0.25
            guard let uiView = uiview else { return }

            // Skip the run loop to make sure form sheet is already adjusted for keyboard presentation
            // and that we can calculate the correct intersection.
            DispatchQueue.main.async {
                let isDocked = keyboardRect.maxY >= UIScreen.main.bounds.maxY

                let newIntersectingKeyboardHeight: CGFloat
                if isDocked, let containerView = uiView.pspdf_closestViewController?.view {
                    let convertedKeyboardRect = containerView.convert(keyboardRect, from: nil)
                    let intersection = convertedKeyboardRect.intersection(containerView.frame)
                    newIntersectingKeyboardHeight = intersection.isNull ? 0 : intersection.height
                } else {
                    newIntersectingKeyboardHeight = 0
                }

                guard self.keyboardView.$intersectingKeyboardHeight.wrappedValue != newIntersectingKeyboardHeight else { return }
                withAnimation(.easeOut(duration: keyboardAnimationDuration)) {
                    self.keyboardView.$intersectingKeyboardHeight.wrappedValue = newIntersectingKeyboardHeight
                }
            }
        }
    }
}

To get the same behavior as in iOS 14 (our logic doesn’t use safe areas) and to keep the code simpler, we disable the automatic avoidance on iOS 14:

extension View {
    func ignoreBuiltInKeyboardAvoidance() -> some View {
        Group {
            if #available(iOS 14.0, *) {
                self.ignoresSafeArea(.keyboard, edges: .bottom)
            } else {
                self
            }
        }
    }
}

Fitting everything together, here’s how KeyboardView is used:

struct SignatureTextView: View {
    @State var intersectingKeyboardHeight: CGFloat = 0

    var body: some View {
        GeometryReader { allMetrics in
            VStack(spacing: 0) {
                textFieldArea(...)

                SignatureFontList(...)

                SignatureBottomToolbar(...)

                KeyboardView(intersectingKeyboardHeight: $intersectingKeyboardHeight)
                    .frame(height: max(intersectingKeyboardHeight - allMetrics.safeAreaInsets.bottom, 0))
                    .fixedSize(horizontal: true, vertical: false)
                    .edgesIgnoringSafeArea(.bottom)
            }
        }
    }
}

Conclusion

We consider our SwiftUI experiment a success. While we weren’t any faster building this feature, we now have a robust set of workarounds to deal with SwiftUI 1’s issues, and we’re more confident in the correctness of our code. Plus, everyone on the team who was involved was excited to learn a new technology. Based on this success, we’ll definitely increase our use of SwiftUI in the future.

Video Presentation

I’ve presented a version of this post at Swift Heroes Digital 2021.

Explore related topics

Share post
Free trial Ready to get started?
Free trial