Display Measurements on PDF Pages or Spreads in Swift for iOS

Shows how to add auxilary views to spreads/pages. Get additional resources by visiting our PSPDFPageView API guide.


//
// Copyright © 2020-2025 PSPDFKit GmbH. All rights reserved.
//
// The Nutrient sample applications are licensed with a modified BSD license.
// Please see License for details. This notice may not be removed from this file.
//
import PSPDFKit
import PSPDFKitUI
/// Swift code from https://www.nutrient.io/guides/ios/customizing-pdf-pages/adding-auxiliary-or-decorative-views/
///
/// For an in-depth explanation of the classes and structs, please read this article, as well as its companion guide
/// “Customizing Interactions with an Annotation Type”.
///
/// This is older sample code that uses measurements as an example. If you actually want measurements don’t use this code.
/// Instead see our Measurement Tools component, which is highlighted in `MeasurementsExample.swift`.
class SpreadMeasurementsExample: Example {
override init() {
super.init()
title = "Auxiliary views on pages"
contentDescription = "Shows how to add auxiliary views to spreads/pages"
category = .viewCustomization
}
private var manager: MeasuringPDFControllerManager?
override func invoke(with delegate: ExampleRunnerDelegate) -> UIViewController? {
manager = .init()
manager?.document = AssetLoader.document(for: .welcome)
return manager?.documentViewController
}
}
// MARK: - Measurement and Datasource:
protocol SpreadMeasurement {
var pageRange: NSRange { get }
var path: CGPath { get }
var value: Measurement<Dimension> { get }
}
protocol DocumentMeasurementDatasource: AnyObject {
func measurements(at pageIndex: Int) -> [SpreadMeasurement]
}
// MARK: - View and Extension:
private extension SpreadMeasurement {
var isArea: Bool {
value.unit is UnitArea
}
}
private class SpreadMeasurementView: UIView, AnnotationPresenting {
var pdfScale: CGFloat {
didSet {
if oldValue != pdfScale, let measurement {
// The transform for PDF to page view coordinates just changed, so we have to adapt
updateFrameAndLayer(measurement: measurement, scale: pdfScale)
}
}
}
var zoomScale: CGFloat {
didSet {
// make sure the label is always crisp
let viewScale = window?.traitCollection.displayScale ?? 1
dimensionLabel.contentScaleFactor = zoomScale * viewScale
}
}
func prepareForReuse() {
measurement = nil
}
var measurement: SpreadMeasurement? {
didSet {
guard let measurement else {
dimensionLabel.isHidden = true
shapeLayer.path = nil
return
}
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]
}
}
}
private func updateFrameAndLayer(measurement: SpreadMeasurement, scale: CGFloat) {
guard scale > 0 else {
return
}
let path = measurement.path
var transform = CGAffineTransform(scaleX: scale, y: scale)
let boundingBox = path.boundingBox.applying(transform)
frame = boundingBox
// The layer is in coordinates of the bounds so we need to account for the offset, too
transform.tx = -boundingBox.origin.x
transform.ty = -boundingBox.origin.y
shapeLayer.path = path.copy(using: &transform)
setNeedsLayout()
}
override init(frame: CGRect) {
pdfScale = 0
zoomScale = 0
shapeLayer = .init()
shapeLayer.lineWidth = 1
shapeLayer.bounds.size = frame.size
shapeLayer.strokeColor = UIColor.systemRed.cgColor
dimensionLabel = UILabel()
dimensionLabel.translatesAutoresizingMaskIntoConstraints = false
dimensionLabel.backgroundColor = UIColor.systemRed.withAlphaComponent(0.6)
dimensionLabel.textColor = .white
formatter = .init()
formatter.unitOptions = .providedUnit
formatter.unitStyle = .short
formatter.numberFormatter.maximumFractionDigits = 2
super.init(frame: frame)
clipsToBounds = false
layer.addSublayer(shapeLayer)
addSubview(dimensionLabel)
NSLayoutConstraint.activate([
dimensionLabel.topAnchor.constraint(equalToSystemSpacingBelow: bottomAnchor, multiplier: 1),
dimensionLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("NSCoding is not supported")
}
private let shapeLayer: CAShapeLayer
private let dimensionLabel: UILabel
private let formatter: MeasurementFormatter
}
// MARK: - Page View:
private 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 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)
}
}
// MARK: - Integration:
private class MeasuringPDFControllerManager: NSObject, PDFViewControllerDelegate {
var measurementsSource: DocumentMeasurementDatasource?
let documentViewController: PDFViewController
var document: Document? {
get { documentViewController.document }
set {
if newValue == nil {
measurementsSource = nil
documentViewController.document = nil
} else if documentViewController.document !== newValue {
// <# create a new measurements datasource for this document here! #>
measurementsSource = newValue.map(MeasurementStore.init(document:)) // this line is removed from guide
// then:
documentViewController.document = newValue
}
}
}
override init() {
documentViewController = PDFViewController { builder in
builder.pageTransition = .scrollContinuous
builder.pageMode = .double
builder.scrollDirection = .vertical
builder.overrideClass(PDFPageView.self, with: MeasurementDisplayingPageView.self)
}
super.init()
documentViewController.delegate = self
}
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 => 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)
}
}
}

This code sample is an example that illustrates how to use our SDK. Please adapt it to your specific use case.