PDFKit: Building a PDF Reader with Annotations and Search
You shipped an enterprise app that downloads compliance documents, and stakeholders now want inline annotations, full-text search, and form filling — all without leaving the app. Opening PDFs in Quick Look or Safari is fine for a prototype, but production apps need control over the rendering pipeline, annotation persistence, and search UX. PDFKit gives you that control with surprisingly little code.
This guide covers PDFView, PDFDocument, PDFAnnotation, text search, form filling, and programmatic PDF generation.
We won’t cover OCR extraction (see Vision OCR Scanning) or camera-based document
scanning.
Contents
- The Problem
- Embedding PDFView in SwiftUI
- Navigating and Searching Documents
- Annotations: Highlights, Notes, and Free Text
- Form Filling with PDFAnnotation Widgets
- Programmatic PDF Creation
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Imagine you are building Pixar’s internal script-review tool. Directors and writers need to open screenplay PDFs, search for scene headings, highlight dialogue, and fill out approval forms — all inside a SwiftUI app running on iPad. Quick Look gives you a read-only viewer, but nothing else.
Here is what a naive approach looks like:
import QuickLook
struct ScriptReviewView: View {
let scriptURL: URL
var body: some View {
// Quick Look: read-only, no annotation API, no search control
QuickLookPreview(url: scriptURL)
}
}
// Simplified for clarity
Quick Look offers zero control over the annotation layer, no programmatic search API, and no way to extract or fill form fields. You need PDFKit.
Apple Docs:
PDFKit— Apple Developer Documentation
Embedding PDFView in SwiftUI
PDFView is a UIKit view. To use it in SwiftUI, wrap it
with UIViewRepresentable. The key is to avoid recreating the PDFView on every SwiftUI state update — create it once
in makeUIView and update it in updateUIView.
import SwiftUI
import PDFKit
struct PixarScriptViewer: UIViewRepresentable {
let document: PDFDocument?
var autoScales: Bool = true
var displayMode: PDFDisplayMode = .singlePageContinuous
func makeUIView(context: Context) -> PDFView {
let pdfView = PDFView()
pdfView.autoScales = autoScales
pdfView.displayMode = displayMode
pdfView.displayDirection = .vertical
pdfView.usePageViewController(false)
return pdfView
}
func updateUIView(_ pdfView: PDFView, context: Context) {
if pdfView.document !== document {
pdfView.document = document
}
}
}
The identity check pdfView.document !== document prevents PDFKit from reloading the same document when SwiftUI
re-renders the parent view. Without it, the user’s scroll position resets on every state change.
Now use it in a parent view that loads documents:
struct ScriptReaderScreen: View {
@State private var document: PDFDocument?
@State private var errorMessage: String?
var body: some View {
Group {
if let document {
PixarScriptViewer(document: document)
} else if let errorMessage {
ContentUnavailableView("Script Not Found",
systemImage: "doc.richtext",
description: Text(errorMessage))
} else {
ProgressView("Loading screenplay...")
}
}
.task {
await loadScript()
}
}
private func loadScript() async {
guard let url = Bundle.main.url(
forResource: "ToyStory5Screenplay",
withExtension: "pdf"
) else {
errorMessage = "Screenplay file missing from bundle."
return
}
// PDFDocument init is synchronous — move off the main actor
let doc = await Task.detached {
PDFDocument(url: url)
}.value
document = doc
}
}
Tip:
PDFDocument(url:)performs file I/O and PDF parsing synchronously. For large documents (100+ pages), always load on a background thread to avoid blocking the main actor.
Accessing Page Metadata
Once you have a PDFDocument, you can inspect its structure. Pixar’s script reviewer might display a table of contents:
func extractChapterInfo(
from document: PDFDocument
) -> [(page: Int, label: String)] {
var chapters: [(page: Int, label: String)] = []
if let outline = document.outlineRoot {
for index in 0..<outline.numberOfChildren {
guard let item = outline.child(at: index),
let label = item.label,
let destination = item.destination,
let page = destination.page else {
continue
}
let pageIndex = document.index(for: page)
chapters.append((page: pageIndex, label: label))
}
}
return chapters
}
The outlineRoot property mirrors the PDF’s bookmark tree. Not every PDF includes one, so always check for nil.
Navigating and Searching Documents
Programmatic Page Navigation
PDFKit makes it straightforward to jump to a specific page:
func goToPage(_ index: Int, in pdfView: PDFView) {
guard let document = pdfView.document,
index >= 0, index < document.pageCount,
let page = document.page(at: index) else {
return
}
pdfView.go(to: page)
}
Full-Text Search
PDFDocument conforms to a delegate-based search
pattern. You call findString(_:withOptions:) and handle results via PDFDocumentDelegate. Here is a production-ready
search coordinator:
final class ScriptSearchCoordinator:
NSObject, PDFDocumentDelegate, ObservableObject {
@Published var results: [PDFSelection] = []
@Published var isSearching = false
private weak var document: PDFDocument?
func attach(to document: PDFDocument) {
self.document = document
document.delegate = self
}
func search(for query: String) {
guard let document, !query.isEmpty else { return }
document.cancelFindString()
results.removeAll()
isSearching = true
document.beginFindString(
query,
withOptions: [.caseInsensitive]
)
}
func cancel() {
document?.cancelFindString()
isSearching = false
}
// MARK: - PDFDocumentDelegate
func didMatchString(_ instance: PDFSelection) {
DispatchQueue.main.async { [weak self] in
self?.results.append(instance)
}
}
func documentDidEndDocumentFind(
_ notification: Notification
) {
DispatchQueue.main.async { [weak self] in
self?.isSearching = false
}
}
}
Call beginFindString(_:withOptions:) instead of findString(_:withOptions:) for asynchronous search on large
documents. The delegate receives matches incrementally, so you can update the UI as results arrive — exactly the
experience users expect when searching through a 200-page Pixar screenplay.
To highlight and navigate to a result:
func navigateToResult(
_ selection: PDFSelection,
in pdfView: PDFView
) {
selection.color = .systemYellow
pdfView.currentSelection = selection
pdfView.go(to: selection)
// Scroll so the selection is centered
if let page = selection.pages.first {
pdfView.go(to: selection.bounds(for: page), on: page)
}
}
Annotations: Highlights, Notes, and Free Text
PDFKit supports the full PDF annotation spec through
PDFAnnotation. The three most common annotation
types for a document reviewer are highlights, sticky notes, and free text.
Adding a Highlight Annotation
When a director selects dialogue to mark as “approved,” you convert the selection into a highlight annotation:
func addHighlight(
for selection: PDFSelection,
color: UIColor = .systemYellow,
in document: PDFDocument
) {
selection.selectionsByLine().forEach { lineSelection in
guard let page = lineSelection.pages.first else {
return
}
let bounds = lineSelection.bounds(for: page)
let highlight = PDFAnnotation(
bounds: bounds,
forType: .highlight,
withProperties: nil
)
highlight.color = color.withAlphaComponent(0.5)
highlight.contents = "Approved by director"
page.addAnnotation(highlight)
}
}
The call to selectionsByLine() is critical. A user selection often spans multiple lines, and a single highlight
annotation covering the entire bounding box would paint over unrelated text. Splitting by line produces precise,
visually correct highlights.
Adding Sticky Notes
Sticky notes appear as icons that expand on tap:
func addStickyNote(
at point: CGPoint,
on page: PDFPage,
text: String,
author: String = "Woody"
) {
let noteSize = CGSize(width: 24, height: 24)
let bounds = CGRect(
origin: CGPoint(
x: point.x - noteSize.width / 2,
y: point.y - noteSize.height / 2
),
size: noteSize
)
let note = PDFAnnotation(
bounds: bounds,
forType: .text,
withProperties: nil
)
note.contents = text
note.userName = author
note.color = .systemOrange
note.iconType = .comment
page.addAnnotation(note)
}
Free Text Annotations
Free text annotations render directly on the page — useful for approval stamps or revision marks:
func addApprovalStamp(on page: PDFPage, at rect: CGRect) {
let stamp = PDFAnnotation(
bounds: rect,
forType: .freeText,
withProperties: nil
)
stamp.contents = "APPROVED - Pixar Story Dept."
stamp.font = UIFont.boldSystemFont(ofSize: 18)
stamp.fontColor = .systemGreen
stamp.color = .clear // Transparent background
stamp.alignment = .center
page.addAnnotation(stamp)
}
Persisting Annotations
Annotations live in-memory until you write the document back to disk. Call dataRepresentation() to serialize:
func saveAnnotatedScript(
_ document: PDFDocument,
to url: URL
) throws {
guard let data = document.dataRepresentation() else {
throw ScriptError.serializationFailed
}
try data.write(to: url, options: .atomic)
}
enum ScriptError: Error {
case serializationFailed
}
Warning:
dataRepresentation()returnsnilif the PDF is encrypted with owner restrictions that forbid modification. Always check the return value.
Form Filling with PDFAnnotation Widgets
PDF forms use widget annotations. PDFKit lets you read and write form field values, which is essential for Pixar’s production approval workflow where crew members fill out release forms digitally.
Reading Form Fields
func extractFormFields(
from document: PDFDocument
) -> [(name: String, value: String?)] {
var fields: [(name: String, value: String?)] = []
for pageIndex in 0..<document.pageCount {
guard let page = document.page(at: pageIndex) else {
continue
}
for annotation in page.annotations
where annotation.type == "Widget" {
let fieldName = annotation.fieldName ?? "Unnamed"
let value = annotation.widgetStringValue
fields.append((name: fieldName, value: value))
}
}
return fields
}
Setting Form Values Programmatically
Imagine pre-populating an approval form with the reviewer’s name and department:
func prefillApprovalForm(
in document: PDFDocument,
reviewerName: String,
department: String
) {
for pageIndex in 0..<document.pageCount {
guard let page = document.page(at: pageIndex) else {
continue
}
for annotation in page.annotations
where annotation.type == "Widget" {
switch annotation.fieldName {
case "reviewer_name":
annotation.widgetStringValue = reviewerName
case "department":
annotation.widgetStringValue = department
case "review_date":
let formatter = DateFormatter()
formatter.dateStyle = .medium
annotation.widgetStringValue =
formatter.string(from: Date())
default:
break
}
}
}
}
Note: Form field names are defined in the PDF itself. You need to know the field names ahead of time — either from the PDF author or by iterating all widget annotations and logging their
fieldNamevalues during development.
Programmatic PDF Creation
Sometimes you need to generate PDFs rather than display them. PDFKit can build documents page by page using Core Graphics rendering. Here is a utility that generates a Pixar character bio sheet:
struct CharacterBio {
let name: String
let movie: String
let voiceActor: String
let bio: String
}
final class CharacterBioGenerator {
private let pageRect = CGRect(
x: 0, y: 0, width: 612, height: 792
) // US Letter
func generatePDF(for characters: [CharacterBio]) -> Data {
let renderer = UIGraphicsPDFRenderer(bounds: pageRect)
return renderer.pdfData { context in
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 24),
.foregroundColor: UIColor.black
]
let bodyAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.darkGray
]
let margin: CGFloat = 50
for character in characters {
context.beginPage()
// Title
let titleRect = CGRect(
x: margin, y: margin,
width: pageRect.width - margin * 2,
height: 40
)
character.name.draw(
in: titleRect,
withAttributes: titleAttributes
)
// Metadata
let metaY = margin + 50
let metaText = """
Movie: \(character.movie)
Voice: \(character.voiceActor)
"""
let metaRect = CGRect(
x: margin, y: metaY,
width: pageRect.width - margin * 2,
height: 50
)
metaText.draw(
in: metaRect,
withAttributes: bodyAttributes
)
// Bio
let bioY = metaY + 70
let bioRect = CGRect(
x: margin, y: bioY,
width: pageRect.width - margin * 2,
height: pageRect.height - bioY - margin
)
character.bio.draw(
in: bioRect,
withAttributes: bodyAttributes
)
}
}
}
}
To turn that Data into a PDFDocument you can display in your viewer:
let bios = [
CharacterBio(
name: "Woody",
movie: "Toy Story",
voiceActor: "Tom Hanks",
bio: "A pull-string cowboy doll and leader of Andy's toys."
),
CharacterBio(
name: "Buzz Lightyear",
movie: "Toy Story",
voiceActor: "Tim Allen",
bio: "A space ranger action figure who believes he is real."
)
]
let generator = CharacterBioGenerator()
let data = generator.generatePDF(for: bios)
let document = PDFDocument(data: data)
Tip: For complex layouts with tables, images, and styled text, consider generating HTML and converting it with
UIMarkupTextPrintFormatterinside aUIPrintPageRenderer. PDFKit’s Core Graphics approach works best for structured, predictable layouts.
Performance Considerations
PDFKit is backed by Core Graphics’ PDF engine, which is efficient but has characteristics you should plan for.
Document loading: PDFDocument(url:) reads the entire file. For documents over 50 MB, you will see a noticeable
stall on older devices. Always load off the main thread. For extremely large documents, consider using CGPDFDocument
directly with page-level lazy loading.
Annotation rendering: Each annotation triggers a draw call during page rendering. Documents with hundreds of annotations per page (common in legal review) may lag during scrolling. Profile with Instruments using the Core Animation template to identify rendering bottlenecks.
Memory: PDFKit caches rendered page images. A 500-page document with large page dimensions can consume several hundred megabytes. Monitor with the Memory Graph Debugger, and consider splitting very large documents into sections.
Search performance: beginFindString scans text content page by page. On a 1,000-page document, a full search takes
2-5 seconds on modern hardware. The incremental delegate callback pattern lets you display results as they arrive, so
perceived performance is much better than actual completion time.
| Operation | Latency (iPhone 15 Pro) | Mitigation |
|---|---|---|
| Load 50-page PDF | ~100ms | Background thread |
| Load 500-page PDF | ~800ms | Background thread + indicator |
| Search (200 pages) | ~500ms | Incremental delegate results |
| Add 100 annotations | ~50ms | Batch updates |
dataRepresentation() | ~200ms | Background thread |
Apple Docs:
PDFView— PDFKit
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| In-app viewing with annotations | PDFKit. Mature and full-spec. |
| Simple read-only PDF display | QuickLookPreview or WKWebView. |
| OCR from scanned PDFs | Vision. |
| Complex reports with charts | HTML-to-PDF or TPPDF library. |
| Cross-platform (iOS + macOS) | PDFKit on both. Wrap conditionally. |
| Form filling programmatically | PDFKit. Only first-party option. |
Summary
PDFViewwrapped inUIViewRepresentablegives you a full-featured PDF viewer in SwiftUI, with scroll, zoom, and page navigation out of the box.- Use
beginFindString(_:withOptions:)withPDFDocumentDelegatefor incremental, non-blocking text search on large documents. PDFAnnotationsupports highlights, sticky notes, free text, and widget (form) annotations — all persist when you calldataRepresentation().- Programmatic PDF creation with
UIGraphicsPDFRendereris straightforward for structured layouts but consider HTML-to-PDF for complex designs. - Always load documents off the main thread and monitor memory for large files.
For extracting text from scanned documents where no text layer exists, explore Vision OCR Scanning to combine VNRecognizeTextRequest with your PDFKit viewer.