Display PDF Annotations on Layers in Swift for iOS
Use Instant’s Layers feature to display different sets of annotations on a document. Get additional resources by visiting our guide on creating PDF annotation layers in iOS.
//// Copyright © 2021-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 Instantimport PSPDFKitimport PSPDFKitUI
class InstantLayersExample: Example {
override init() { super.init()
title = "Instant Layers Example" contentDescription = "Shows how to display different sets of annotations on a document using Instant Layers." category = .collaboration priority = 3
wantsModalPresentation = true embedModalInNavigationController = false }
weak var presentingViewController: UIViewController?
override func invoke(with delegate: ExampleRunnerDelegate) -> UIViewController? { let currentViewController = delegate.currentViewController! presentNewSession(on: currentViewController) presentingViewController = currentViewController
return nil }
/// Connects to the Example API Client to get the document info for the current example /// and then displays that document in a `InstantViewController`. func presentNewSession(on viewController: UIViewController) { // URL of the client server (your server) that has the list of documents the particular user can access. // See https://www.nutrient.io/guides/ios/instant-synchronization/ for more details. let apiClient = WebExamplesAPIClient(delegate: self)
let progressHUDItem = StatusHUDItem.indeterminateProgress(withText: "Creating") progressHUDItem.setHUDStyle(.black)
// Append UUID to the layer names to create unique layers for each session. let sessionUUID = UUID().uuidString let layerNames = ["Review-1", "Review-2", "Review-3"].map { $0.appending(sessionUUID) }
progressHUDItem.push(animated: true, on: viewController.view.window) { // Ask your server to create a new session for the given user and the specified document identifier. // It should ideally provide a signed JWT (and the Nutrient Document Engine if not already available) // that can be used by the `InstantClient` to access and download the document for iOS. apiClient.createNewSession(with: layerNames) { result in DispatchQueue.main.async { progressHUDItem.pop(animated: true, completion: nil)
switch result { case let .success(documentLayers): // Process the instant document layers to remove the appended unique identifier // from the layer name so the name is a human readable label. var processedLayers = [String: InstantDocumentInfo]() for (key, value) in documentLayers { let processedKey = key.replacingOccurrences(of: sessionUUID, with: "") processedLayers[processedKey] = value } self.presentInstantViewController(for: processedLayers, on: viewController) case let .failure(error): viewController.showAlert(withTitle: "Couldn’t Get Instant Document Info", message: error.localizedDescription) } } } } }
private func presentInstantViewController(for documentLayers: [String: InstantDocumentInfo], on viewController: UIViewController) { let instantViewController = MultipleInstantLayersContainerViewController(documentLayers: documentLayers) instantViewController.modalPresentationStyle = .fullScreen viewController.present(instantViewController, animated: true) }
}
private class MultipleInstantLayersContainerViewController: UIViewController, UISplitViewControllerDelegate {
// MARK: Properties
/// Controller containing the Instant Layers listing and the Document view. private var containedSplitViewController: UISplitViewController
/// Controller displaying the Instant Document in the content view of the split view controller. var instantController: ContainedInstantDocumentViewController
/// Controller displaying the list of available Instant Layers in the sidebar of the split view controller. var instantLayersListController: InstantLayersListViewController
/// Controller added as the sidebar. Adds `instantLayersListController` as a child controller. var sidebarContainerController: SidebarControllersContainingViewController
/// Button displayed in the navigation bar to show/hide the sidebar. private lazy var sidebarToggleButton: UIBarButtonItem = { let sidebarToggleIconImage = PSPDFKit.SDK.imageNamed("document_outline") let button = UIBarButtonItem(image: sidebarToggleIconImage, style: .plain, target: self, action: #selector(togglesSidebar(_:))) button.title = "Document Layers" return button }()
init(documentLayers: [String: InstantDocumentInfo]) { instantLayersListController = InstantLayersListViewController(documentLayers: documentLayers) instantLayersListController.title = "Layers"
// Upon initialization `InstantLayersListViewController` defaults to the first layer. // This is why we use `selectedLayerName` to access the first layer to display. let selectedLayerName = instantLayersListController.selectedLayerName instantController = ContainedInstantDocumentViewController(documentInfo: documentLayers[selectedLayerName]!) instantController.title = "Instant Layers Example"
// `instantLayersListController` instance needs to know if a new session was started by // the `instantController` so that it can add the new session layer to the listing. instantController.documentInfoSessionDelegate = instantLayersListController
sidebarContainerController = SidebarControllersContainingViewController(childViewController: instantLayersListController)
let sidebarController = PDFNavigationController(rootViewController: sidebarContainerController) let contentController = PDFNavigationController(rootViewController: instantController)
// Create a `UISplitViewController` with the above controllers. let splitController = UISplitViewController(style: .doubleColumn) splitController.setViewController(sidebarController, for: .primary) splitController.setViewController(contentController, for: .secondary)
splitController.preferredDisplayMode = .oneBesideSecondary containedSplitViewController = splitController
// We want to disable showing the `displayModeButton` so we can use our custom button to toggle sidebar. // Disabling gestures also disables showing the `displayModeButton`. // For consistency we disable gestures on both.#if !os(visionOS) splitController.presentsWithGesture = false#endif
super.init(nibName: nil, bundle: nil)
// Assign the `instantController` as the delegate of the `InstantLayersListViewController` // so the `instantController` instance can update the document layer it is presenting. instantLayersListController.delegate = self
// We add our own bar button item to toggle the sidebar to get our desired sidebar behavior. instantController.navigationItem.setLeftBarButtonItems([sidebarToggleButton], for: .document, animated: false) instantController.navigationItem.setRightBarButtonItems([instantController.exampleCloseButtonItem, instantController.annotationButtonItem, instantController.collaborateButtonItem], for: .document, animated: false) }
@available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() { super.viewDidLoad()
addChild(containedSplitViewController) view.addSubview(containedSplitViewController.view) containedSplitViewController.didMove(toParent: self)
containedSplitViewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ containedSplitViewController.view.topAnchor.constraint(equalTo: view.topAnchor), containedSplitViewController.view.leftAnchor.constraint(equalTo: view.leftAnchor), containedSplitViewController.view.rightAnchor.constraint(equalTo: view.rightAnchor), containedSplitViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) }
/// Shows or hides the sidebar based on its current state. @objc func togglesSidebar(_ sender: Any?) { // Check whether there's enough space for sidebar to expand. if !containedSplitViewController.isCollapsed { let currentDisplayMode = containedSplitViewController.displayMode
/// Whether the sidebar is already visible or not. let showSidebar: Bool#if os(visionOS) showSidebar = currentDisplayMode != .oneBesideSecondary#else showSidebar = !(currentDisplayMode == .oneOverSecondary || currentDisplayMode == .oneBesideSecondary)#endif
sidebarContainerController.addContainedViewControllerIfNecessary()
// We will use the modern API to show/hide the sidebar column (`.primary`). if showSidebar { self.containedSplitViewController.show(.primary) } else { self.containedSplitViewController.hide(.primary) } } else { // If the sidebar cannot be expanded then that means we are in compact width. // So we will have to present the controller presented in the sidebar modally. sidebarContainerController.presentContainedViewControllerModally(on: instantController) } }
func ensureDocumentViewIsShown() { let isInOneOverSecondary: Bool#if os(visionOS) isInOneOverSecondary = false#else isInOneOverSecondary = containedSplitViewController.displayMode == .oneOverSecondary#endif if !containedSplitViewController.isCollapsed && isInOneOverSecondary { containedSplitViewController.show(.secondary) } }
}
extension MultipleInstantLayersContainerViewController: InstantLayersListViewControllerDelegate {
func instantLayersListController(_ instantLayersListController: InstantLayersListViewController, didSelectLayer selectedLayer: InstantDocumentInfo, selectedLayerIndex: UInt) { // Change the document displayed by the instant controller to correspond to the corresponding // document of the selected layer name in the Layers list. instantController.changeVisibleDocument(to: selectedLayer, clearLocalStorage: false) instantController.displayingLayerIndex = selectedLayerIndex ensureDocumentViewIsShown() }}
/// `InstantDocumentViewController` subclass that preloads the document with annotations/// upon download.private class ContainedInstantDocumentViewController: InstantDocumentViewController {
override init(documentInfo: InstantDocumentInfo, lastViewedDocumentInfoKey: String? = nil) { super.init(documentInfo: documentInfo, lastViewedDocumentInfoKey: lastViewedDocumentInfoKey)
// We don't want to allow creating new sessions for this document and the example. collaborationOptionsConfiguration = InstantCollaborationOptionsViewControllerConfiguration( documentIdentifierForNewSession: documentInfo.documentId, allowJoiningExistingSessions: true, allowCreatingNewSessions: false, allowsOpeningArbitraryDocuments: false ) }
/// Index of the Selected Layer corresponding to the `InstantLayersListViewController` instance. var displayingLayerIndex: UInt = 0
/// Backing store of the indexes of the document layers that have had annotation preloaded. var preloadedLayerIndexes: Set<UInt> = []
override func didFinishDownload(for documentInfo: InstantDocumentInfo) { // Add some default annotations to distinguish between the different layers. // We add these annotations when the downloading of the document has been completed. if !preloadedLayerIndexes.contains(displayingLayerIndex) { let preloadedAnnotations = InstantLayersExampleAnnotationsHelper.annotationsForLayer(at: displayingLayerIndex) document?.add(annotations: preloadedAnnotations) preloadedLayerIndexes.insert(displayingLayerIndex) } }
}
private protocol InstantLayersListViewControllerDelegate: AnyObject {
/// `InstantDocumentInfo` of the layer selected from the list of layers presented by the `InstantLayersListViewController`. func instantLayersListController(_ instantLayersListController: InstantLayersListViewController, didSelectLayer selectedLayer: InstantDocumentInfo, selectedLayerIndex: UInt)}
/// Displays a list of the available layers for the ongoing session shown in the sidebar for the/// Instant Layers Example./// The list allows selecting between the available layers and passing on that information to its delegate./// In this example, the delegate is the `InstantViewController` subclass that will update the document it is/// editing to the document selected in the list.private class InstantLayersListViewController: UITableViewController, InstantDocumentViewControllerDelegate {
/// Backing store of `InstantDocumentInfo` for the document layers to be listed stored against /// the layer name as their key. private(set) var documentLayers: [String: InstantDocumentInfo] { didSet { layerNames = Array(documentLayers.keys.sorted()) } }
/// Layer names listed by the controller. /// Convenience access for the sorted keys of `documentLayers`. private(set) var layerNames: [String]
/// Index of the selected layer name. private(set) var selectedLayerIndex = 0
var selectedLayerName: String { layerNames[selectedLayerIndex] }
weak var delegate: InstantLayersListViewControllerDelegate?
init(documentLayers: [String: InstantDocumentInfo]) { self.documentLayers = documentLayers self.layerNames = Array(documentLayers.keys.sorted())
super.init(nibName: nil, bundle: nil) }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
let cellIdentifier = "InstantLayerCellIdentifier"
override func viewDidLoad() { super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier) tableView.tableFooterView = UIView() }
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated)
let indexPath = IndexPath(row: selectedLayerIndex, section: 0) tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) }
override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) tableView.deselectRow(at: (IndexPath(row: selectedLayerIndex, section: 0)), animated: false) }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { layerNames.count }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) cell.selectedBackgroundView = UIView() cell.selectedBackgroundView?.backgroundColor = .systemBlue cell.textLabel?.text = layerNames[indexPath.row] cell.textLabel?.highlightedTextColor = .white return cell }
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { selectedLayerIndex = indexPath.row let selectedLayerName = layerNames[selectedLayerIndex] let selectedLayer = documentLayers[selectedLayerName]! delegate?.instantLayersListController(self, didSelectLayer: selectedLayer, selectedLayerIndex: UInt(selectedLayerIndex))
// Dismiss the list if it is presented as a modal individually i.e not in a `splitViewController`. if splitViewController == nil { dismiss(animated: true) } }
func instantDocumentController(_ instantDocumentController: InstantDocumentViewController, didCreateNewSession documentInfo: InstantDocumentInfo) { let layerCount = layerNames.count let newLayerName = "Review-\(layerCount + 1)" documentLayers[newLayerName] = documentInfo tableView.reloadData()
selectedLayerIndex = layerCount let indexPath = IndexPath(row: selectedLayerIndex, section: 0) tableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) }}
extension InstantLayersExample: WebExamplesAPIClientDelegate {
func examplesAPIClient(_ apiClient: WebExamplesAPIClient, didReceiveBasicAuthenticationChallenge challenge: URLAuthenticationChallenge, completion: @escaping (URLCredential?) -> Void) { presentingViewController?.presentBasicAuthPrompt(for: challenge) { username, password in guard let user = username, let pass = password else { completion(nil) return }
let urlCredential = URLCredential(user: user, password: pass, persistence: .permanent) completion(urlCredential) } }
}
// MARK: - Preloading Annotations Helper
/// A wrapper for the helper functions which create annotations/// that are used by the InstantLayerExample to preload the different/// layers with annotations.private struct InstantLayersExampleAnnotationsHelper {
static func annotationsForLayer(at index: UInt) -> [Annotation] { switch index { case 0: return annotationsForLayer1() case 1: return annotationsForLayer2() case 2: return annotationsForLayer3() default: return annotationsForLayer1() } }
private static func annotationsForLayer1() -> [Annotation] { var annotations = [Annotation]()
let draftStamp = StampAnnotation(stampType: .draft) draftStamp.boundingBox = CGRect(x: 1065, y: 421, width: 90, height: 21) annotations.append(draftStamp)
let noteAnnotation = NoteAnnotation(contents: "I like the choice of bio degradable lighting.") noteAnnotation.color = .red noteAnnotation.boundingBox = CGRect(x: 240, y: 620, width: 32, height: 32) annotations.append(noteAnnotation)
let freeTextAnnotation = FreeTextAnnotation(contents: "We need lighting in the hallway as well") freeTextAnnotation.color = .red freeTextAnnotation.boundingBox = CGRect(x: 592, y: 495, width: 97, height: 32) freeTextAnnotation.fontSize = 9 annotations.append(freeTextAnnotation)
let lineAnnotation = LineAnnotation(point1: CGPoint(x: 596, y: 499), point2: CGPoint(x: 500, y: 416)) lineAnnotation.lineEnd2 = .closedArrow lineAnnotation.fillColor = UIColor(red: 0.87, green: 0.27, blue: 0.30, alpha: 1) lineAnnotation.color = UIColor(red: 0.87, green: 0.27, blue: 0.30, alpha: 1) annotations.append(lineAnnotation)
return annotations }
private static func annotationsForLayer2() -> [Annotation] { var annotations = [Annotation]()
let needsRevisionStamp = StampAnnotation(title: "Needs Revision") needsRevisionStamp.boundingBox = CGRect(x: 1065, y: 421, width: 90, height: 21) annotations.append(needsRevisionStamp)
let lightingsComment = FreeTextAnnotation(contents: "We can add more lights here.") lightingsComment.boundingBox = CGRect(x: 506, y: 246, width: 128, height: 15) lightingsComment.borderColor = .blue lightingsComment.fontSize = 9.5 annotations.append(lightingsComment)
let squigglyAnnotation = SquareAnnotation() squigglyAnnotation.borderEffect = .cloudy squigglyAnnotation.borderColor = .blue squigglyAnnotation.borderEffectIntensity = 1 squigglyAnnotation.lineWidth = 1 squigglyAnnotation.boundingBox = CGRect(x: 485, y: 240, width: 162, height: 54) annotations.append(squigglyAnnotation)
return annotations }
private static func annotationsForLayer3() -> [Annotation] { var annotations = [Annotation]()
let approvedStamp = StampAnnotation(stampType: .approved) approvedStamp.boundingBox = CGRect(x: 748, y: 286, width: 275, height: 95) annotations.append(approvedStamp)
let freeText = FreeTextAnnotation(contents: "We are good to go!") freeText.boundingBox = CGRect(x: 755, y: 144, width: 275, height: 98) freeText.borderColor = .green freeText.fontSize = 40 annotations.append(freeText)
return annotations }}
This code sample is an example that illustrates how to use our SDK. Please adapt it to your specific use case.