Adding Auxiliary or Decorative Views

Some behaviors or appearance customizations can only be achieved by adding custom views to a page. If, for example, you want to customize interactions with a certain annotation type, or you want to display additional UI elements next to certain annotations, or you want to place auxiliary UI elements at certain positions of pages that satisfy particular criteria, adding custom views may be your best option to achieve this.

This guide covers the case where you want to place auxiliary or decorative content on a page. This is an advanced technique, so before continuing down this path, please first look at the companion guide, Customizing Interactions with an Annotation Type. In particular, you should check whether or not your use case can be expressed in terms of an existing annotation type in combination with custom data. Suppose, for example, you want a persistent magnifying glass. That case can be solved by adding a custom property to a CircleAnnotation and then proceeding with the approach shown in the companion guide.

Use Case: Displaying Measurements

One example of a view that has no relation to annotations but would need to stay fixed in relation to page content is a tool that allows measuring the length of or surface area covered by elements on a page. So let’s build the basic user interface elements for that.

ℹ️ Note: To see this example in action, look for Measurements on Pages/Spreads (SpreadMeasurementsExample.swift) in our Catalog app. The full source code is available there as well.

For the purpose of this example, we’ll assume we already have a SpreadMeasurementDatasource that can give us an array of SpreadMeasurement objects for any page in a document (neither the implementation of this data source nor how the measurements were created matter):

protocol SpreadMeasurement {
    var pageRange: NSRange { get }
    var path: CGPath { get }
    var value: Measurement<Dimension> { get }
}

protocol DocumentMeasurementDatasource: AnyObject {
    func measurements(at pageIndex: Int) -> [SpreadMeasurement]
}
@protocol PSCSpreadMeasurement <NSObject>
@property (nonatomic, readonly) NSRange pageRange;
@property (nonatomic, readonly) CGPathRef path;
@property (nonatomic, readonly) NSMeasurement<__kindof NSDimension *> *value;
@end

@protocol PSCSpreadMeasurementDatasource <NSObject>
- (nullable NSArray<id<PSCSpreadMeasurement>> *)measurementsForPageAtIndex:(PSPDFPageIndex)pageIndex;
@end

ℹ️ Note: PSPDFKit doesn’t currently support measurement properties as specified in section 8.8 of the PDF reference. The only reason why displaying the results of a hypothetical measurement tool has been chosen for this example is because it’s a relatable use case.

The only interesting/noteworthy bit about these protocols is that a measurement can span multiple pages. This is why it can’t be expressed in terms of an existing annotation type, like PolygonAnnotation: An annotation can only ever relate to one page at a time. But for many real-world documents you might want to measure, the element whose dimensions you’re interested in can span two or more pages. For the sake of simplicity, we assume that any multi-page SpreadMeasurement only spans pages whose coordinate systems can be transformed to each other by using only a translation — no scaling or rotation occurs.

With all these simplifications in place, we can set out to build our SpreadMeasurementView based on CAShapeLayer:

class SpreadMeasurementView: UIView, AnnotationPresenting {
    private let shapeLayer: CAShapeLayer
    private let dimensionLabel: UILabel
    private let formatter: MeasurementFormatter

    var measurement: SpreadMeasurement? {
        didSet {
            guard let measurement = measurement else {
                // No measurement and nothing to show.
                dimensionLabel.isHidden = true
                shapeLayer.path = nil

                return
            }

            // Update for the new value — areas should be clearly distinguishable from other measurements.
            dimensionLabel.isHidden = false
            dimensionLabel.text = formatter.string(from: measurement.value)
            updateFrameAndLayer(measurement: measurement, scale: pdfScale)
            if measurement.isArea {
                shapeLayer.fillColor = UIColor(white: 0.2, alpha: 0.4).cgColor
                shapeLayer.lineDashPattern = nil
            } else {
                shapeLayer.fillColor = nil
                shapeLayer.lineDashPattern = [5, 3, 2, 3]
            }
        }
    }

    var pdfScale: CGFloat
    var zoomScale: CGFloat

    // To be continued…
}

extension SpreadMeasurement {
    var isArea: Bool {
        value.unit is UnitArea
    }
}
@interface PSCSpreadMeasurementView : UIView <PSPDFAnnotationPresenting>

@property (nonatomic) CGFloat PDFScale;
@property (nonatomic) CGFloat zoomScale;
@property (nonatomic, nullable) id<PSCSpreadMeasurement> measurement;

@end

// And in the `.m` file:

@implementation PSCSpreadMeasurementView {
    CAShapeLayer *_shapeLayer;
    NSMeasurementFormatter *_formatter;
    UILabel *_dimensionLabel;
}

- (void)setMeasurement:(id<PSCSpreadMeasurement>)measurement {
    if (measurement == _measurement) {
        return;
    }

    _measurement = measurement;
    if (measurement == nil) {
        _dimensionLabel.hidden = YES;
        _shapeLayer.path = nil;
    } else {
        _dimensionLabel.hidden = NO;
        _dimensionLabel.text = [_formatter stringFromMeasurement:measurement.value];
        if (PSCMeasurementIsArea(measurement)) {
            _shapeLayer.fillColor = [UIColor colorWithWhite:0.2 alpha:0.4].CGColor;
            _shapeLayer.lineDashPattern = nil;
        } else {
            _shapeLayer.fillColor = nil;
            _shapeLayer.lineDashPattern = @[@5, @3, @2, @3];
        }
        [self updateFrameAndLayerForMeasurement:measurement scale:self.PDFScale];
    }
}

static BOOL PSCMeasurementIsArea(id<PSCSpreadMeasurement> measurement) {
    return [measurement.value.unit isKindOfClass:NSUnitArea.class];
}

// To be continued…

@end

Although the thing our view class is going to display isn’t an annotation, we’ll conform it to AnnotationPresenting. The reasoning for this non-obvious choice is twofold:

  1. The natural coordinate system for the points in the path of SpreadMeasurement is that of the PDF page. This means the view needs to update its own frame whenever the transform from page to page view coordinates changes.

  2. While CAShapeLayer automatically renders its path at the appropriate resolution, UILabel needs some help to stay crisp when we zoom in.

PDFPageView automatically forwards these events to the subviews of its annotationContainerView — but only if they conform to AnnotationPresenting. Because the entire API in this protocol is optional, we can pick just the parts we need — namely the properties pdfScale and zoomScale — and update accordingly if either one changes.

So let’s look at the implementations for those properties, along with the helper method we’ve omitted:

var pdfScale: CGFloat {
        didSet {
            if oldValue != pdfScale, let measurement = measurement {
                // The transform for PDF to page view coordinates just changed, so we have to adapt accordingly.
                updateFrameAndLayer(measurement: measurement, scale: pdfScale)
            }
        }
    }

    var zoomScale: CGFloat {
        didSet {
            // Make sure the label is always crisp — `CAShapeLayer` takes care of this automagically.
            dimensionLabel.contentScaleFactor = zoomScale
        }
    }

    private func updateFrameAndLayer(measurement: SpreadMeasurement, scale: CGFloat) {
        guard scale > 0 else {
            return
        }

        // The measurement is in page coordinates, but we need to place ourselves in view coordinates.
        let path = measurement.path
        var transform = CGAffineTransform(scaleX: scale, y: scale)
        let boundingBox = path.boundingBox.applying(transform)
        frame = boundingBox

        // The layer itself is in coordinates of our bounds so we need to account for the offset when updating the path.
        transform.tx = -boundingBox.origin.x
        transform.ty = -boundingBox.origin.y
        shapeLayer.path = path.copy(using: &transform)
        setNeedsLayout()
    }
- (void)setPDFScale:(CGFloat)PDFScale {
    if (PDFScale == _PDFScale) {
        return;
    }

    _PDFScale = PDFScale;
    // The transform for PDF to page view coordinates just changed, so we have to adapt accordingly.
    [self updateFrameAndLayerForMeasurement:self.measurement scale:PDFScale];
}

- (void)setZoomScale:(CGFloat)zoomScale {
    if (zoomScale == _zoomScale) {
        return;
    }

    _zoomScale = zoomScale;
    // Make sure the label is always crisp — `CAShapeLayer` takes care of this automagically.
    _dimensionLabel.contentScaleFactor = zoomScale;
}

- (void)updateFrameAndLayerForMeasurement:(id<PSCMeasurement>)measurement scale:(CGFloat)scale {
    if (measurement == nil || scale <= 0) {
        return;
    }

    // The measurement is in page coordinates, but we need to place ourselves in view coordinates.
    CGPathRef path = measurement.path;
    CGAffineTransform transform = CGAffineTransformMakeScale(scale, scale);
    CGRect boundingBox = CGRectApplyAffineTransform(CGPathGetPathBoundingBox(path), transform);
    self.frame = boundingBox;

    // The layer itself is in coordinates of our bounds so we need to account for the offset when updating the path.
    transform.tx = -boundingBox.origin.x;
    transform.ty = -boundingBox.origin.y;
    shapeLayer.path = CGPathCreateCopyByTransformingPath(path, &transform);
    [self setNeedsLayout];
}

To actually make use of this new class, we’re going to subclass PDFPageView and extend it with the functionality to reuse instances of our measurement view in a manner similar to what UITableView and UICollectionView do:

class MeasurementDisplayingPageView: PDFPageView {
    private var measureViewReusePool = [SpreadMeasurementView]()
    private var visibleMeasureViews = [SpreadMeasurementView]()

    override func prepareForReuse() {
        visibleMeasureViews.forEach { view in
            view.isHidden = true
            view.prepareForReuse()
        }
        measureViewReusePool.append(contentsOf: visibleMeasureViews)
        visibleMeasureViews.removeAll(keepingCapacity: true)

        super.prepareForReuse()
    }

    func dequeueMeasureView() -> SpreadMeasurementView {
        let view = measureViewReusePool.popLast() ?? SpreadMeasurementView()
        view.isHidden = false
        // Ensure the measure view is added to the annotation container view — `PDFPageView.prepareForReuse()`
        // removes it from the view hierarchy. Also, make sure the view has the correct scales set so that it
        // displays correctly.
        annotationContainerView.addSubview(view)
        visibleMeasureViews.append(view)
        view.pdfScale = scaleForPageView
        view.zoomScale = zoomView?.zoomScale ?? 1

        return view
    }

    func markForReuse(measureView: SpreadMeasurementView) {
        measureView.prepareForReuse()
        measureView.isHidden = true
        visibleMeasureViews.removeAll {
            $0 === measureView
        }
        measureViewReusePool.append(measureView)
    }
}
NS_ASSUME_NONNULL_BEGIN

@interface PSCMeasurementDisplayingPageView : PSPDFPageView

- (PSCSpreadMeasurementView *)dequeueMeasureView;

- (void)markMeasurementViewForReuse:(PSCSpreadMeasurementView *)view;

@end

NS_ASSUME_NONNULL_END

// And in the `.m` file:

@implementation PSCMeasurementDisplayingPageView {
    NSMutableArray<PSCSpreadMeasurementView *> *_measureViewReusePool;
    NSMutableArray<PSCSpreadMeasurementView *> *_visibleMeasureViews;
}

- (instancetype)initWithFrame:(CGRect)frame {
    if ((self = [super initWithFrame:frame])) {
        _measureViewReusePool = [NSMutableArray new];
        _visibleMeasureViews = [NSMutableArray new];
    }
    return self;
}

- (void)prepareForReuse {
    for (PSCSpreadMeasurementView *view in _visibleMeasureViews) {
        view.hidden = YES;
        [view prepareForReuse];
        [_measureViewReusePool addObject:view];
    }
    [_visibleMeasureViews removeAllObjects];

    [super prepareForReuse];
}

- (PSCSpreadMeasurementView *)dequeueMeasureView {
    PSCSpreadMeasurementView *view = _measureViewReusePool.lastObject;
    if (view == nil) {
        view = [[PSCSpreadMeasurementView alloc] initWithFrame:CGRectZero];
    } else {
        [_measureViewReusePool removeLastObject];
        view.hidden = NO;
    }

    /*
     Ensure the measure view is added to the annotation container view — `-[PSPDFPageView prepareForReuse]` removes
     it from the view hierarchy. Also, make sure the view has the correct scales set so that it displays correctly.
     */
    [self.annotationContainerView addSubview:view];
    [_visibleMeasureViews addObject:view];
    view.PDFScale = self.scaleForPageView;
    view.zoomScale = self.zoomView.zoomScale;

    return view;
}

- (void)markMeasurementViewForReuse:(PSCSpreadMeasurementView *)view {
    [view prepareForReuse];
    view.hidden = YES;
    [_visibleMeasureViews removeObjectIdenticalTo:view];
    [_measureViewReusePool addObject:view];
}

@end

The last step is to register the override and dequeue measurement views at the appropriate time. Because we chose a simplistic data source that doesn’t support modifications of the data it hosts, we only need to do the latter when a PDFPageView is configured for display. There’s a method in PDFViewControllerDelegate for this exact purpose, so we’ll use that.

To wrap it all up and put the pieces together, we’ll introduce a new class that’s responsible for configuring and managing the PDF view controller:

class MeasuringPDFControllerManager: NSObject, PDFViewControllerDelegate {
    var measurementsSource: DocumentMeasurementDatasource?
    let documentViewController: PDFViewController

    func pdfViewController(_ pdfController: PDFViewController, didConfigurePageView pageView: PDFPageView, forPageAt pageIndex: Int) {
        guard
            let page = pageView as? MeasurementDisplayingPageView,
            let allMeasurements = measurementsSource?.measurements(at: pageIndex)
        else {
            return
        }

        for measurement in allMeasurements
        // A measurement can span multiple pages, so we need to make sure we don’t add one to more than one page at once.
        where measurement.pageRange.location == pageIndex {
            let view = page.dequeueMeasureView()
            view.measurement = measurement
        }

        /*
         Ensure the second page in a spread is always below the first one in the hierarchy to allow measurements to
         reach across the page binding.
         */
        if pdfController.configuration.pageMode == .double && pageIndex % 2 == 0 {
            page.superview?.sendSubviewToBack(page)
        }
    }
}
NS_ASSUME_NONNULL_BEGIN
@interface PSCMeasuringPDFControllerManager : NSObject <PSPDFViewControllerDelegate>

@property (nonatomic, readonly) PSPDFViewController *pdfViewController;

@end
NS_ASSUME_NONNULL_END

// In the `.m` file:
@implementation PSCMeasuringPDFControllerManager {
    id<PSCDocumentMeasurementDatasource> _measurementDatasource;
}

- (void)pdfViewController:(PSPDFViewController *)pdfController didConfigurePageView:(PSPDFPageView *)pageView forPageAtIndex:(NSInteger)pageIndex {
    if ([pageView isKindOfClass:PSCMeasurementDisplayingPageView.class] == NO) {
        return;
    }

    PSCMeasurementDisplayingPageView *page = (id)pageView;
    NSArray<id<PSCSpreadMeasurement>> *allMeasurements = [_measurementDatasource measurementsForPageAtIndex:pageIndex];
    for (id<PSCSpreadMeasurement> measurement in allMeasurements) {
        // A measurement can span multiple pages, so we need to make sure we don’t add one to more than one page at once.
        if (measurement.pageRange.location == (NSUInteger)pageIndex) {
            PSCSpreadMeasurementView *view = [page dequeueMeasureView];
            view.measurement = measurement;
        }
    }
    /*
     Ensure the second page in a spread is always below the first one in the hierarchy to allow measurements to reach
     across the page binding.
     */
    if (pdfController.configuration.pageMode == PSPDFPageModeDouble &&
        pageIndex % 2 == 0) {
        [page.superview sendSubviewToBack:page];
    }
}

@end

In a more realistic scenario, you’d also need to add and remove measurements to the visible page views using the “mark for reuse” method our PDFPageView subclass has added, but that — as well as the implementation of the measurements data source — is out of scope for this article.