```html SwiftUI: How to Build a PDF Viewer (iOS 17+, 2026)

How to Build a PDF Viewer in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: PDFKit, PDFView, PDFDocument Updated: May 12, 2026
TL;DR

SwiftUI has no native PDF view, so wrap Apple's PDFView from PDFKit in a UIViewRepresentable. Set autoScales = true, assign a PDFDocument, and you have a pinch-to-zoom, scroll-to-page PDF viewer in under 20 lines.

import PDFKit
import SwiftUI

struct PDFViewer: UIViewRepresentable {
    let url: URL

    func makeUIView(context: Context) -> PDFView {
        let view = PDFView()
        view.autoScales = true
        view.displayMode = .singlePageContinuous
        view.document = PDFDocument(url: url)
        return view
    }

    func updateUIView(_ uiView: PDFView, context: Context) {
        uiView.document = PDFDocument(url: url)
    }
}

Full implementation

The complete viewer adds a toolbar with previous/next page buttons, a live page counter, and a share sheet so users can export the PDF. A @State URL drives the whole view — swap the URL and the document reloads automatically. Because PDFView tracks its own scroll position internally, we reach into it via Coordinator only for page-jump actions.

import PDFKit
import SwiftUI

// MARK: - UIViewRepresentable wrapper

struct PDFViewer: UIViewRepresentable {
    let url: URL
    @Binding var currentPage: Int
    let totalPages: (Int) -> Void

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    func makeUIView(context: Context) -> PDFView {
        let pdfView = PDFView()
        pdfView.autoScales       = true
        pdfView.displayMode      = .singlePageContinuous
        pdfView.displayDirection = .vertical
        pdfView.backgroundColor  = .secondarySystemBackground

        if let doc = PDFDocument(url: url) {
            pdfView.document = doc
            totalPages(doc.pageCount)
        }

        NotificationCenter.default.addObserver(
            context.coordinator,
            selector: #selector(Coordinator.pageChanged(_:)),
            name: .PDFViewPageChanged,
            object: pdfView
        )
        return pdfView
    }

    func updateUIView(_ uiView: PDFView, context: Context) {
        // Reload document when URL changes
        if let doc = uiView.document, doc.documentURL != url,
           let newDoc = PDFDocument(url: url) {
            uiView.document = newDoc
            totalPages(newDoc.pageCount)
        }
        // Respond to programmatic page navigation
        if let doc = uiView.document,
           let page = doc.page(at: currentPage - 1),
           uiView.currentPage !== page {
            uiView.go(to: page)
        }
    }

    // MARK: Coordinator
    class Coordinator: NSObject {
        var parent: PDFViewer
        init(_ parent: PDFViewer) { self.parent = parent }

        @objc func pageChanged(_ notification: Notification) {
            guard let pdfView = notification.object as? PDFView,
                  let doc     = pdfView.document,
                  let page    = pdfView.currentPage else { return }
            let index = doc.index(for: page) + 1          // 1-based
            DispatchQueue.main.async {
                self.parent.currentPage = index
            }
        }
    }
}

// MARK: - Host view

struct PDFViewerScreen: View {
    let url: URL
    @State private var currentPage = 1
    @State private var totalPages  = 1
    @State private var showShare   = false

    var body: some View {
        NavigationStack {
            PDFViewer(url: url, currentPage: $currentPage) { count in
                totalPages = count
            }
            .ignoresSafeArea(edges: .bottom)
            .navigationTitle(url.lastPathComponent)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItemGroup(placement: .bottomBar) {
                    Button {
                        if currentPage > 1 { currentPage -= 1 }
                    } label: {
                        Label("Previous page", systemImage: "chevron.up")
                    }
                    .disabled(currentPage <= 1)

                    Spacer()
                    Text("\(currentPage) / \(totalPages)")
                        .monospacedDigit()
                        .font(.caption)
                        .foregroundStyle(.secondary)
                    Spacer()

                    Button {
                        if currentPage < totalPages { currentPage += 1 }
                    } label: {
                        Label("Next page", systemImage: "chevron.down")
                    }
                    .disabled(currentPage >= totalPages)
                }

                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showShare = true
                    } label: {
                        Label("Share PDF", systemImage: "square.and.arrow.up")
                    }
                }
            }
            .sheet(isPresented: $showShare) {
                ShareLink(item: url, preview: SharePreview(url.lastPathComponent))
            }
        }
    }
}

// MARK: - Preview

#Preview {
    // Use the iOS sample PDF bundled in the app bundle
    if let url = Bundle.main.url(forResource: "sample", withExtension: "pdf") {
        PDFViewerScreen(url: url)
    } else {
        // Fallback: create a tiny in-memory PDF for canvas previews
        let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792))
        let data = renderer.pdfData { ctx in
            ctx.beginPage()
            "Hello, PDF!".draw(at: CGPoint(x: 72, y: 72),
                               withAttributes: [.font: UIFont.systemFont(ofSize: 24)])
        }
        let tmpURL = FileManager.default.temporaryDirectory.appendingPathComponent("preview.pdf")
        try? data.write(to: tmpURL)
        return PDFViewerScreen(url: tmpURL)
    }
}

How it works

  1. UIViewRepresentable bridge. SwiftUI cannot render PDFView natively, so makeUIView creates and configures one UIKit instance. updateUIView is called whenever SwiftUI detects state changes — we guard against needless document reloads by comparing documentURL before replacing the document.
  2. Page-change notifications. PDFKit posts .PDFViewPageChanged whenever the user scrolls to a new page. The Coordinator observes this notification and writes back to the @Binding var currentPage, keeping the "X / Y" counter in sync without any polling.
  3. Programmatic navigation. When the toolbar's prev/next buttons mutate currentPage, SwiftUI calls updateUIView again. We call pdfView.go(to:) only when the PDFView's own current page differs from the requested one — avoiding an infinite feedback loop with the notification observer.
  4. autoScales + displayMode. autoScales = true fits the page width to the screen on launch. .singlePageContinuous with .vertical direction gives a natural scroll-through reading experience identical to Apple Books.
  5. ShareLink. iOS 16+ ShareLink handles the share sheet for the PDF URL. Because the PDF is already on disk (bundle or temp directory), the system can preview it inline in the share sheet without any extra conversion.

Variants

Two-up (side-by-side) page layout

func makeUIView(context: Context) -> PDFView {
    let pdfView = PDFView()
    pdfView.autoScales       = true
    // Show two pages at once — great on iPad or landscape iPhone
    pdfView.displayMode      = .twoUp
    pdfView.displayDirection = .horizontal
    pdfView.usePageViewController(true, withViewOptions: nil)
    pdfView.document = PDFDocument(url: url)
    return pdfView
}

// usePageViewController gives a native page-curl / swipe transition
// and ensures correct two-page spread rendering on larger screens.

Jump directly to a named destination or page number

PDFKit supports named destinations (anchors embedded in the PDF) via PDFDocument.namedDestinations and pdfView.go(to:) accepting a PDFDestination. For simple page jumps, retrieve the page with doc.page(at: index) (zero-based) and pass it to pdfView.go(to: page). Combine this with a TextField or Slider in your toolbar for a scrubber-style jump control — useful for academic papers or large manuals where users need to skip to a specific chapter.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a pdf viewer in SwiftUI for iOS 17+.
Use PDFKit (PDFView, PDFDocument, PDFPage).
Make it accessible (VoiceOver labels for page counter and toolbar buttons).
Add prev/next toolbar buttons, a live "X / Y" page counter, and a ShareLink.
Add a #Preview with realistic sample data (generate an in-memory PDF if no bundle resource is found).

Paste this into Soarias during the Build phase after your screens are scaffolded — Claude Code will wire up the UIViewRepresentable, toolbar, and share sheet in one pass, leaving you only UI polish to do.

Related

FAQ

Does this work on iOS 16?

The UIViewRepresentable/PDFView pattern works on iOS 11+. The only iOS 17+ dependency in this guide is the #Preview macro — on iOS 16 you would use PreviewProvider instead. ShareLink requires iOS 16+, so if you target iOS 15 replace it with a UIActivityViewController wrapper. Everything else is backward-compatible.

Can I display a PDF from a remote URL (HTTPS)?

Yes, but PDFDocument(url:) performs a synchronous network fetch on whichever thread you call it from — never call it on the main thread for remote URLs. Download the data with URLSession in a background Task, write it to a temporary file, then initialise PDFDocument(url: tmpURL) and pass the result back to the main actor. Alternatively use PDFDocument(data:) directly with the downloaded Data.

What is the UIKit equivalent?

In UIKit you'd add PDFView as a subview directly and constrain it with Auto Layout — no UIViewRepresentable needed. Page-change notifications and pdfView.go(to:) work identically. For document picking you'd present a UIDocumentPickerViewController and handle the result in its delegate method. The SwiftUI wrapper in this guide provides the same surface area with far less boilerplate.

Last reviewed: 2026-05-12 by the Soarias team.

```