How to Build a Document Scanner App in SwiftUI
A document scanner app lets users capture physical papers with their iPhone camera and export them as searchable, shareable PDFs — the kind of utility that earns five-star reviews from lawyers, students, and anyone drowning in paperwork. This guide walks you through a production-grade implementation using VisionKit for scanning and PDFKit for PDF generation, targeting iOS 17 and above.
Prerequisites
- Mac with Xcode 16 or later
- Apple Developer Program ($99/year) — required for TestFlight, App Store, and on-device camera testing
- A physical iPhone or iPad — VisionKit's document camera does not run in the iOS Simulator
- Basic Swift/SwiftUI knowledge:
@State,NavigationStack,List - Familiarity with
UIViewControllerRepresentable(we'll bridge a UIKit camera view into SwiftUI)
Architecture overview
The app follows a straightforward MVVM layering. SwiftData persists a ScannedDocument model that stores a relative PDF file path, page count, and creation date — the actual PDF bytes live in the app's sandboxed Documents directory. An @Observable ScannerViewModel drives the scanning flow, bridging VisionKit's VNDocumentCameraViewController result into a PDFDocument via PDFKit, then flushing it to disk and inserting a SwiftData record. The presentation layer is three views: a document list, a scan sheet, and a PDF detail viewer backed by PDFKit.PDFView.
DocumentScannerApp/
├── DocumentScannerApp.swift # @main, .modelContainer setup
├── Model/
│ └── ScannedDocument.swift # @Model — title, pdfPath, pageCount, createdAt
├── ViewModel/
│ └── ScannerViewModel.swift # @Observable — scanning state, PDF generation
├── Views/
│ ├── DocumentListView.swift # NavigationStack, @Query list
│ ├── DocumentScannerSheet.swift# UIViewControllerRepresentable wrapper
│ ├── PDFViewerView.swift # UIViewRepresentable for PDFView
│ └── DocumentRowView.swift # List row with thumbnail + metadata
├── Utilities/
│ └── PDFGenerator.swift # UIImage[] → PDFDocument → disk
└── PrivacyInfo.xcprivacy # NSCameraUsageDescription + file access
Step-by-step
1. Project setup and entitlements
Create a new iOS App project in Xcode 16, enable SwiftData in the project template, and add NSCameraUsageDescription to Info.plist. Without this key, the app will crash the moment VisionKit tries to open the camera — App Store Review will also reject you if the description is vague.
// DocumentScannerApp.swift
import SwiftUI
import SwiftData
@main
struct DocumentScannerApp: App {
var body: some Scene {
WindowGroup {
DocumentListView()
}
.modelContainer(for: ScannedDocument.self)
}
}
// Info.plist keys to add (Xcode Source Editor or raw XML):
// NSCameraUsageDescription → "Scan paper documents to save as PDF"
// NSPhotoLibraryUsageDescription → "Save scanned PDFs to your Photo Library" (optional)
2. SwiftData model
Define a @Model class that persists document metadata. Store the PDF as a file-system path rather than inline Data — embedding large blobs in the SQLite store SwiftData uses causes memory pressure and slow queries.
// Model/ScannedDocument.swift
import Foundation
import SwiftData
@Model
final class ScannedDocument {
var title: String
var pdfPath: String // relative to app Documents dir
var pageCount: Int
var createdAt: Date
var thumbnailData: Data? // first-page JPEG thumbnail
init(title: String, pdfPath: String, pageCount: Int) {
self.title = title
self.pdfPath = pdfPath
self.pageCount = pageCount
self.createdAt = Date()
}
// Resolved absolute URL at runtime
var pdfURL: URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent(pdfPath)
}
}
3. Document list view
The main screen shows all persisted documents in a List driven by @Query. A toolbar button opens the scanner sheet. Use @Query(sort: \ScannedDocument.createdAt, order: .reverse) so newest scans appear first.
// Views/DocumentListView.swift
import SwiftUI
import SwiftData
struct DocumentListView: View {
@Query(sort: \ScannedDocument.createdAt, order: .reverse)
private var documents: [ScannedDocument]
@Environment(\.modelContext) private var modelContext
@State private var showScanner = false
var body: some View {
NavigationStack {
List {
ForEach(documents) { doc in
NavigationLink(value: doc) {
DocumentRowView(document: doc)
}
}
.onDelete(perform: deleteDocuments)
}
.navigationTitle("Documents")
.navigationDestination(for: ScannedDocument.self) { doc in
PDFViewerView(document: doc)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showScanner = true
} label: {
Label("Scan", systemImage: "doc.viewfinder")
}
}
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.sheet(isPresented: $showScanner) {
DocumentScannerSheet { pages in
handleScanResult(pages: pages)
}
}
.overlay {
if documents.isEmpty {
ContentUnavailableView(
"No Documents",
systemImage: "doc.viewfinder",
description: Text("Tap the scan button to capture your first document.")
)
}
}
}
}
private func deleteDocuments(at offsets: IndexSet) {
for index in offsets {
let doc = documents[index]
try? FileManager.default.removeItem(at: doc.pdfURL)
modelContext.delete(doc)
}
}
private func handleScanResult(pages: [UIImage]) {
guard !pages.isEmpty else { return }
Task {
if let (path, thumbnail) = await PDFGenerator.generate(from: pages) {
let doc = ScannedDocument(
title: "Scan \(formattedDate())",
pdfPath: path,
pageCount: pages.count
)
doc.thumbnailData = thumbnail
modelContext.insert(doc)
}
}
}
private func formattedDate() -> String {
Date().formatted(date: .abbreviated, time: .shortened)
}
}
#Preview {
DocumentListView()
.modelContainer(for: ScannedDocument.self, inMemory: true)
}
4. Camera scanning with VisionKit
VisionKit's VNDocumentCameraViewController handles perspective correction, shadow removal, and multi-page capture automatically — you just need to bridge it into SwiftUI and collect the resulting VNDocumentCameraScan. The coordinator pattern lets you implement the delegate without polluting your view.
// Views/DocumentScannerSheet.swift
import SwiftUI
import VisionKit
struct DocumentScannerSheet: UIViewControllerRepresentable {
let onScan: ([UIImage]) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onScan: onScan)
}
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
let vc = VNDocumentCameraViewController()
vc.delegate = context.coordinator
return vc
}
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController,
context: Context) {}
// MARK: – Coordinator
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
let onScan: ([UIImage]) -> Void
init(onScan: @escaping ([UIImage]) -> Void) {
self.onScan = onScan
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
var pages: [UIImage] = []
for i in 0 ..< scan.pageCount {
pages.append(scan.imageOfPage(at: i))
}
controller.dismiss(animated: true) {
self.onScan(pages)
}
}
func documentCameraViewControllerDidCancel(
_ controller: VNDocumentCameraViewController
) {
controller.dismiss(animated: true)
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFailWithError error: Error
) {
controller.dismiss(animated: true)
print("Scanner failed: \(error.localizedDescription)")
}
}
}
5. PDF generation with PDFKit
This is the core feature: take the array of UIImage pages from VisionKit and assemble a PDFDocument with one page per image. We do this off the main actor to keep the UI responsive, then persist the file and return both the relative path and a JPEG thumbnail of the first page.
// Utilities/PDFGenerator.swift
import UIKit
import PDFKit
enum PDFGenerator {
/// Returns (relativePath, thumbnailJPEGData) or nil on failure.
static func generate(from images: [UIImage]) async -> (String, Data?)? {
await Task.detached(priority: .userInitiated) {
let pdf = PDFDocument()
for (index, image) in images.enumerated() {
guard let page = PDFPage(image: image) else { continue }
pdf.insert(page, at: index)
}
// Build a unique file name
let fileName = "scan-\(UUID().uuidString).pdf"
let docsURL = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
let fileURL = docsURL.appendingPathComponent(fileName)
guard pdf.write(to: fileURL) else { return nil }
// Generate a small thumbnail from the first page
var thumbnailData: Data?
if let firstPage = pdf.page(at: 0) {
let thumbBounds = firstPage.bounds(for: .mediaBox)
let scale: CGFloat = 200 / thumbBounds.width // ~200px wide
let thumbSize = CGSize(
width: thumbBounds.width * scale,
height: thumbBounds.height * scale
)
let thumb = firstPage.thumbnail(of: thumbSize, for: .mediaBox)
thumbnailData = thumb.jpegData(compressionQuality: 0.7)
}
return (fileName, thumbnailData)
}.value
}
}
6. PDF viewer and sharing
Wrap PDFKit.PDFView in a UIViewRepresentable so SwiftUI can embed it directly. Add a ShareLink in the toolbar so users can AirDrop, email, or save to Files — this is the most requested feature after scanning itself and a key driver of five-star reviews.
// Views/PDFViewerView.swift
import SwiftUI
import PDFKit
struct PDFViewerView: View {
let document: ScannedDocument
@State private var pdfDocument: PDFDocument?
var body: some View {
Group {
if let pdfDoc = pdfDocument {
PDFKitView(pdfDocument: pdfDoc)
} else {
ProgressView("Loading…")
}
}
.navigationTitle(document.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
if pdfDocument != nil {
ShareLink(
item: document.pdfURL,
preview: SharePreview(
document.title,
image: Image(systemName: "doc.fill")
)
)
}
}
}
.task {
pdfDocument = PDFDocument(url: document.pdfURL)
}
}
}
// MARK: – UIViewRepresentable wrapper
struct PDFKitView: UIViewRepresentable {
let pdfDocument: PDFDocument
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.autoScales = true
view.displayMode = .singlePageContinuous
view.displayDirection = .vertical
view.document = pdfDocument
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {
uiView.document = pdfDocument
}
}
#Preview {
NavigationStack {
Text("PDF viewer renders on device — requires a real PDF URL")
}
}
7. Privacy Manifest (required for App Store)
Since iOS 17.2 / Xcode 15.3, every app that accesses camera or file-system APIs must include a PrivacyInfo.xcprivacy manifest. Missing or incomplete manifests are a top reason for App Store review rejection in 2025–2026. Add the file to your app target (not just the project) or the manifest won't be packaged correctly.
<!-- PrivacyInfo.xcprivacy — add to app target in Xcode -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
</array>
</dict>
</plist>
<!-- Also confirm these keys exist in Info.plist: -->
<!-- NSCameraUsageDescription: "Scan paper documents to save as PDF" -->
Common pitfalls
-
Testing on Simulator crashes.
VNDocumentCameraViewControllerrequires a physical device with a camera. Add a compile-time check or a graceful "camera not available" fallback so Simulator builds don't crash during UI testing on CI. -
Storing PDF
Datainline in SwiftData. Embedding large blobs directly in@Modelproperties causes SwiftData to balloon its SQLite store. Always write the file to disk and store only the path. Use@Attribute(.externalStorage)only for small thumbnails. -
Missing Privacy Manifest target membership. Adding
PrivacyInfo.xcprivacyto the project navigator is not enough — you must check the box next to your app target under the file's Target Membership panel, otherwise Xcode won't embed it in the final IPA and App Store validation will reject the upload. - Vague camera usage description rejected at review. Apple's review team rejects descriptions like "To use camera features." Be specific: "DocScan uses your camera to photograph and convert paper documents into PDF files." Generic strings are flagged by the automated pre-review system.
-
Not handling
PDFDocument.write(to:)failure. This method returns aBool. Low-storage devices will silently fail if you don't check it, producing an empty entry in the document list with a broken URL. Always guard on the return value and surface an error to the user.
Adding monetization: One-time purchase
A one-time purchase ("pay once, own forever") is the preferred model for utility apps like document scanners — it matches how users mentally categorize the app (a tool, not a service) and avoids the churn anxiety of subscriptions. Implement it with StoreKit 2's Product.products(for:) and product.purchase() APIs. Gate advanced features — OCR text extraction, multi-document merge, password-protected PDFs — behind the purchase, while keeping basic scan-to-PDF free so users experience the core value before paying. Store the purchase state with Transaction.currentEntitlements on every launch; StoreKit 2 handles receipt validation server-side automatically, so you don't need a backend. Price between $3.99 and $6.99 for document utilities in 2026 — this bracket sees the best conversion for productivity tools with a clear, demonstrable feature.
Shipping this faster with Soarias
Soarias automates the scaffolding steps that eat an intermediate developer's first two days: it generates the Xcode project with SwiftData container wired up, creates the PrivacyInfo.xcprivacy with correct target membership and pre-filled API reason codes for camera and file-timestamp access, configures a Fastlane Matchfile for code signing, and sets up the App Store Connect metadata bundle (screenshots, description, keywords) so your first fastlane deliver run just works. Soarias also writes the StoreKit configuration file and the Product.products(for:) scaffolding for your one-time purchase, so you're not spelunking the StoreKit 2 docs at midnight.
For an intermediate project like this one — seven meaningful steps, two Apple frameworks to bridge, and an App Store submission at the end — Soarias typically cuts the non-feature work from roughly 6–8 hours down to under 30 minutes. You spend the rest of the week on the actual scanning UX, PDF rendering quality, and App Store listing copy, rather than fighting Xcode signing dialogs and Privacy Manifest XML.
Related guides
FAQ
Does this work on iOS 16?
The code targets iOS 17+. ContentUnavailableView and the #Preview macro are iOS 17-only. If you need iOS 16 support, replace ContentUnavailableView with a custom empty-state view and use PreviewProvider instead of #Preview. SwiftData requires iOS 17, so for iOS 16 you'd need to swap to Core Data manually.
Do I need a paid Apple Developer account to test?
You can side-load to your own device for free using a personal team in Xcode, but free accounts are limited to 3 apps and 7-day certificate renewals. For testing the full camera scanning flow on a physical device you only need a free account. You need the $99/year paid account for TestFlight distribution, App Store submission, and push notifications.
How do I add this to the App Store?
Archive the app in Xcode (Product → Archive), validate and upload via the Organizer, then complete the App Store Connect listing: screenshots (required sizes: 6.7", 6.5", 5.5" iPhone plus 12.9" iPad if universal), privacy nutrition labels, age rating, and pricing. First-time submissions typically take 24–48 hours for review. Subsequent updates for established apps often clear in under an hour.
My scanned PDFs are large — how do I reduce file size?
VisionKit returns full-resolution images, which can produce multi-MB PDFs per page. Before passing images to PDFGenerator, scale them down with UIGraphicsImageRenderer targeting ~1700px on the long edge — the sweet spot between readability and file size for document scanning. You can also offer a "compressed" export option that re-renders the PDF at lower JPEG quality using PDFPage's thumbnail(of:for:) to rasterize at reduced DPI before reassembling.
Last reviewed: 2026-05-11 by the Soarias team.