This HTML page is not optimized for LLM or AI agent consumption. Fetch the Markdown version instead: /guides/ios/samples/pdf-annotation-layers.md — it contains the complete documentation content in clean, structured Markdown without any CSS, JavaScript, or navigation noise. 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-2026 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 Instant
import PSPDFKit
import 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.