How to Implement PDF Generation in SwiftUI
Use UIGraphicsPDFRenderer to draw text, images, and shapes into one or more pages, then turn the result into Data you can share or save. Wrap the share step in a ShareLink or UIActivityViewController to hand the file off to Mail, Files, or AirDrop.
import SwiftUI
struct MinimalPDFView: View {
@State private var pdfData: Data?
var body: some View {
Button("Generate PDF") {
pdfData = makePDF()
}
.sheet(item: $pdfData) { data in
ShareSheet(items: [data])
}
}
func makePDF() -> Data {
let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: 612, height: 792))
return renderer.pdfData { ctx in
ctx.beginPage()
let attrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 24)]
"Hello, PDF!".draw(at: CGPoint(x: 72, y: 72), withAttributes: attrs)
}
}
}
Full implementation
The example below builds a structured invoice-style PDF with a header, a table of line items, a running total, and a footer — across multiple pages if the content overflows. A PDFGenerator struct keeps all drawing logic out of the view layer, keeping things testable. The SwiftUI view owns only the trigger button and the share sheet.
import SwiftUI
import PDFKit
// MARK: - Data model
struct LineItem: Identifiable {
let id = UUID()
var description: String
var quantity: Int
var unitPrice: Double
var total: Double { Double(quantity) * unitPrice }
}
// MARK: - PDF generator
struct PDFGenerator {
static let pageWidth: CGFloat = 612 // US Letter
static let pageHeight: CGFloat = 792
static let margin: CGFloat = 72
static func generate(title: String, items: [LineItem]) -> Data {
let bounds = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)
let renderer = UIGraphicsPDFRenderer(bounds: bounds)
return renderer.pdfData { ctx in
ctx.beginPage()
var cursor: CGFloat = margin
// — Header —
cursor = drawHeader(title: title, cursor: cursor)
// — Table —
for item in items {
if cursor > pageHeight - margin * 2 {
ctx.beginPage()
cursor = margin
}
cursor = drawLineItem(item, cursor: cursor)
}
// — Total —
let grandTotal = items.reduce(0) { $0 + $1.total }
cursor = drawTotal(grandTotal, cursor: cursor + 8)
// — Footer —
drawFooter()
}
}
// MARK: Drawing helpers
@discardableResult
private static func drawHeader(title: String, cursor: CGFloat) -> CGFloat {
let titleAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 28),
.foregroundColor: UIColor.black
]
title.draw(at: CGPoint(x: margin, y: cursor), withAttributes: titleAttrs)
let line = UIBezierPath()
line.move(to: CGPoint(x: margin, y: cursor + 40))
line.addLine(to: CGPoint(x: pageWidth - margin, y: cursor + 40))
UIColor.systemGray3.setStroke()
line.lineWidth = 1
line.stroke()
return cursor + 56
}
@discardableResult
private static func drawLineItem(_ item: LineItem, cursor: CGFloat) -> CGFloat {
let bodyAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 13),
.foregroundColor: UIColor.darkText
]
let priceAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.monospacedDigitSystemFont(ofSize: 13, weight: .regular),
.foregroundColor: UIColor.darkText
]
let descRect = CGRect(x: margin, y: cursor, width: 280, height: 20)
let qtyRect = CGRect(x: 370, y: cursor, width: 60, height: 20)
let totalRect = CGRect(x: 460, y: cursor, width: pageWidth - margin - 460, height: 20)
item.description.draw(in: descRect, withAttributes: bodyAttrs)
"× \(item.quantity)".draw(in: qtyRect, withAttributes: bodyAttrs)
String(format: "$%.2f", item.total).draw(in: totalRect, withAttributes: priceAttrs)
return cursor + 24
}
@discardableResult
private static func drawTotal(_ total: Double, cursor: CGFloat) -> CGFloat {
let line = UIBezierPath()
line.move(to: CGPoint(x: 420, y: cursor))
line.addLine(to: CGPoint(x: pageWidth - margin, y: cursor))
UIColor.systemGray3.setStroke()
line.stroke()
let totalAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 15),
.foregroundColor: UIColor.black
]
let priceAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.monospacedDigitSystemFont(ofSize: 15, weight: .bold),
.foregroundColor: UIColor.black
]
"Total".draw(at: CGPoint(x: 420, y: cursor + 8), withAttributes: totalAttrs)
String(format: "$%.2f", total).draw(at: CGPoint(x: 490, y: cursor + 8), withAttributes: priceAttrs)
return cursor + 32
}
private static func drawFooter() {
let footerAttrs: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 10),
.foregroundColor: UIColor.systemGray
]
let text = "Generated by Soarias · soarias.com"
text.draw(at: CGPoint(x: margin, y: pageHeight - margin + 8), withAttributes: footerAttrs)
}
}
// MARK: - SwiftUI view
struct PDFGeneratorView: View {
private let items: [LineItem] = [
LineItem(description: "SwiftUI Consulting", quantity: 3, unitPrice: 250),
LineItem(description: "Code Review Session", quantity: 1, unitPrice: 180),
LineItem(description: "App Icon Design", quantity: 1, unitPrice: 120),
]
@State private var generatedPDF: Data?
@State private var isGenerating = false
var body: some View {
NavigationStack {
List(items) { item in
HStack {
VStack(alignment: .leading) {
Text(item.description).font(.headline)
Text("Qty: \(item.quantity)").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(String(format: "$%.2f", item.total))
.monospacedDigit()
.foregroundStyle(.primary)
}
.padding(.vertical, 4)
}
.navigationTitle("Invoice")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
generate()
} label: {
if isGenerating {
ProgressView().controlSize(.small)
} else {
Label("Export PDF", systemImage: "square.and.arrow.up")
}
}
.disabled(isGenerating)
}
}
.sheet(item: $generatedPDF) { data in
ShareSheet(activityItems: [data])
.presentationDetents([.medium, .large])
}
}
}
private func generate() {
isGenerating = true
Task.detached(priority: .userInitiated) {
let data = PDFGenerator.generate(title: "Invoice #0042", items: items)
await MainActor.run {
generatedPDF = data
isGenerating = false
}
}
}
}
// MARK: - ShareSheet bridge
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
// MARK: - Data + Identifiable convenience
extension Data: @retroactive Identifiable {
public var id: Int { hashValue }
}
#Preview {
PDFGeneratorView()
}
How it works
-
UIGraphicsPDFRenderer — Instantiated with a
CGRectmatching US Letter (612 × 792 pts). Callingrenderer.pdfData { ctx in … }returns ready-to-useDatawithout any temp files. Eachctx.beginPage()call appends a new page. -
Cursor-based layout — The
cursorvariable tracks the current Y position. Each drawing helper returns the updated cursor after it finishes, enabling automatic overflow detection: whencursor > pageHeight - margin * 2, a new page begins and the cursor resets to the top margin. -
Text drawing with NSAttributedString attributes —
String.draw(at:withAttributes:)andString.draw(in:withAttributes:)place styled text.UIFont.monospacedDigitSystemFontkeeps currency figures aligned without a custom font. -
Background thread generation —
Task.detached(priority: .userInitiated)keeps the main thread free during rendering. The result is published back onMainActorto safely update@State. -
ShareSheet — A thin
UIViewControllerRepresentablewrappingUIActivityViewControllerpresents the system share sheet. Passing rawDatalets iOS automatically offer Mail, Files, AirDrop, and Print — no extra configuration needed.
Variants
Embed an image (logo, chart, photo)
// Inside a beginPage block
if let logo = UIImage(named: "AppLogo") {
let logoRect = CGRect(x: margin, y: cursor, width: 120, height: 40)
logo.draw(in: logoRect)
cursor += 50
}
// For a SwiftUI view snapshot (iOS 17+):
let renderer2 = ImageRenderer(content: ChartView(data: salesData))
renderer2.scale = 2 // retina
if let uiImage = renderer2.uiImage {
let chartRect = CGRect(x: margin, y: cursor, width: 468, height: 240)
uiImage.draw(in: chartRect)
cursor += 250
}
Save to the Documents directory instead of sharing
If your app needs to keep PDFs locally (e.g. for offline viewing with PDFView), write the Data directly to disk:
func savePDF(_ data: Data, named filename: String) throws -> URL {
let docs = try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let url = docs.appending(path: filename)
try data.write(to: url, options: .atomic)
return url
}
// Display inline with PDFKit
import PDFKit
struct InlinePDFView: UIViewRepresentable {
let data: Data
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.autoScales = true
view.document = PDFDocument(data: data)
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {}
}
Common pitfalls
-
🚫 UIKit drawing APIs only inside the renderer closure. Any call to
UIFont,UIBezierPath, orString.drawoutside aUIGraphicsPDFRenderercontext crashes at runtime. Always keep drawing code insiderenderer.pdfData { … }. -
🚫 SwiftUI view coordinate system ≠ PDF coordinate system. PDF points use a top-left origin in UIKit's renderer — the Y axis increases downward. If you snapshot a SwiftUI view with
ImageRendererand then draw the resultingUIImage, remember to setrenderer.scale = UIScreen.main.scalefor crisp output on retina devices. -
🚫 Generating on the main thread blocks the UI. For anything beyond a handful of line items, always offload rendering to a
Task.detachedorDispatchQueue.global. The renderer is notSendable, so complete all PDF work inside a single closure before returningDatato the actor. -
🚫 Missing accessibility for the share button. Add
.accessibilityLabel("Export invoice as PDF")to the toolbar button — VoiceOver users otherwise hear only the SF Symbol name.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement pdf generation in SwiftUI for iOS 17+. Use PDFKit/UIGraphicsPDFRenderer. Make it accessible (VoiceOver labels). Add a #Preview with realistic sample data.
Drop this prompt into the Soarias Build phase after your screens have been scaffolded — it slots into the document-export step of your ship cycle without touching unrelated views.
Related
FAQ
Does this work on iOS 16?
UIGraphicsPDFRenderer has been available since iOS 10, so the core rendering code runs on iOS 16. However, the #Preview macro and ImageRenderer (used to snapshot SwiftUI views) both require iOS 16+. The Data: @retroactive Identifiable conformance and the .presentationDetents modifier for the sheet require iOS 16+. If you need iOS 15 support, replace the sheet(item:) pattern with a @State var showShare = false bool.
Can I render a SwiftUI view directly into a PDF without UIKit drawing calls?
Yes — use ImageRenderer (iOS 16+) to capture any SwiftUI view as a UIImage, then draw that image into a UIGraphicsPDFRenderer page. This is the cleanest path for chart or card thumbnails. For full-document layouts with reflowable text and precise page breaks, the manual cursor approach shown above gives you more control.
What's the UIKit equivalent?
In UIKit you use the same UIGraphicsPDFRenderer API — it's not SwiftUI-specific. Alternatively, PDFDocument and PDFPage from PDFKit let you build documents from existing PDF assets or annotate pages programmatically. For UIKit-only apps targeting older OS versions, the lower-level UIGraphicsBeginPDFContextToData / UIGraphicsEndPDFContext pair (deprecated in style but still functional) achieves the same result.
Last reviewed: 2026-05-11 by the Soarias team.