How to Build a PDF Viewer in SwiftUI
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
-
UIViewRepresentable bridge. SwiftUI cannot render
PDFViewnatively, somakeUIViewcreates and configures one UIKit instance.updateUIViewis called whenever SwiftUI detects state changes — we guard against needless document reloads by comparingdocumentURLbefore replacing the document. -
Page-change notifications. PDFKit posts
.PDFViewPageChangedwhenever the user scrolls to a new page. TheCoordinatorobserves this notification and writes back to the@Binding var currentPage, keeping the "X / Y" counter in sync without any polling. -
Programmatic navigation. When the toolbar's prev/next buttons mutate
currentPage, SwiftUI callsupdateUIViewagain. We callpdfView.go(to:)only when thePDFView's own current page differs from the requested one — avoiding an infinite feedback loop with the notification observer. -
autoScales + displayMode.
autoScales = truefits the page width to the screen on launch..singlePageContinuouswith.verticaldirection gives a natural scroll-through reading experience identical to Apple Books. -
ShareLink. iOS 16+
ShareLinkhandles 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
-
iOS 16 compatibility:
PDFViewitself is available back to iOS 11, butShareLinkrequires iOS 16+. If you need iOS 15 support, replaceShareLinkwith aUIActivityViewControllerwrapped inUIViewControllerRepresentable. -
Memory pressure on large PDFs: PDFKit streams pages lazily, but loading a
300-page document with embedded high-res images can still spike memory during thumbnail generation.
Set
pdfView.pageShadowsEnabled = falseand avoid pre-rendering all thumbnails up front — generate them on demand withPDFPage.thumbnail(of:for:)in a backgroundTask. -
Feedback loop between Coordinator and updateUIView: Writing back to
@Binding var currentPageinside the notification handler triggersupdateUIView, which in turn callspdfView.go(to:). Always guard withuiView.currentPage !== page(pointer identity) before jumping, otherwise the view stutters mid-scroll. -
Accessibility — VoiceOver page announcements: PDFKit's
PDFViewposts its own accessibility notifications, but the page counter label in your toolbar will not be read automatically. Add.accessibilityLabel("Page \(currentPage) of \(totalPages)")to theTextcounter so VoiceOver users always know their position.
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.