```html How to Build a Document Scanner App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: ~1 week Updated: May 11, 2026

Prerequisites

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

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.

```