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

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.

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)
}

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() returns nil if 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 fieldName values 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 UIMarkupTextPrintFormatter inside a UIPrintPageRenderer. 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.

OperationLatency (iPhone 15 Pro)Mitigation
Load 50-page PDF~100msBackground thread
Load 500-page PDF~800msBackground thread + indicator
Search (200 pages)~500msIncremental delegate results
Add 100 annotations~50msBatch updates
dataRepresentation()~200msBackground thread

Apple Docs: PDFView — PDFKit

When to Use (and When Not To)

ScenarioRecommendation
In-app viewing with annotationsPDFKit. Mature and full-spec.
Simple read-only PDF displayQuickLookPreview or WKWebView.
OCR from scanned PDFsVision.
Complex reports with chartsHTML-to-PDF or TPPDF library.
Cross-platform (iOS + macOS)PDFKit on both. Wrap conditionally.
Form filling programmaticallyPDFKit. Only first-party option.

Summary

  • PDFView wrapped in UIViewRepresentable gives you a full-featured PDF viewer in SwiftUI, with scroll, zoom, and page navigation out of the box.
  • Use beginFindString(_:withOptions:) with PDFDocumentDelegate for incremental, non-blocking text search on large documents.
  • PDFAnnotation supports highlights, sticky notes, free text, and widget (form) annotations — all persist when you call dataRepresentation().
  • Programmatic PDF creation with UIGraphicsPDFRenderer is 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.