๐Ÿ“„ PDFKit

PDF ํ‘œ์‹œ, ์ฃผ์„, ๊ฒ€์ƒ‰, ์ƒ์„ฑ์„ ์œ„ํ•œ ์™„์ „ํ•œ ์†”๋ฃจ์…˜

iOS 11+macOS ์ง€์›

โœจ PDFKit์ด๋ž€?

PDFKit์€ PDF ๋ฌธ์„œ๋ฅผ ํ‘œ์‹œ, ํŽธ์ง‘, ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๋Š” ๊ฐ•๋ ฅํ•œ ํ”„๋ ˆ์ž„์›Œํฌ์ž…๋‹ˆ๋‹ค. ํŽ˜์ด์ง€ ๋„ค๋น„๊ฒŒ์ด์…˜, ํ™•๋Œ€/์ถ•์†Œ, ๊ฒ€์ƒ‰, ์ฃผ์„ ์ถ”๊ฐ€, ์ธ๋„ค์ผ ํ‘œ์‹œ ๋“ฑ PDF ์ž‘์—…์— ํ•„์š”ํ•œ ๋ชจ๋“  ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. SwiftUI์™€ UIKit ๋ชจ๋‘์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ๋ฌธ์„œ ๋ทฐ์–ด ์•ฑ, ์ „์ž์ฑ… ๋ฆฌ๋”, ์ฃผ์„ ๋„๊ตฌ ๋“ฑ์„ ๋งŒ๋“ค ๋•Œ ํ•„์ˆ˜์ ์ž…๋‹ˆ๋‹ค.

๐Ÿ’ก ํ•ต์‹ฌ ๊ธฐ๋Šฅ: PDF ํ‘œ์‹œ ยท ํŽ˜์ด์ง€ ๋„ค๋น„๊ฒŒ์ด์…˜ ยท ๊ฒ€์ƒ‰ ยท ์ฃผ์„ ์ถ”๊ฐ€ ยท ์ธ๋„ค์ผ ยท PDF ์ƒ์„ฑ ยท ํ…์ŠคํŠธ ์„ ํƒ ยท ํ™•๋Œ€/์ถ•์†Œ ยท ๋ถ๋งˆํฌ

๐ŸŽฏ 1. PDF ํ‘œ์‹œ ๊ธฐ๋ณธ

PDFView๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ PDF ๋ฌธ์„œ๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

PDFViewerView.swift โ€” ๊ธฐ๋ณธ PDF ๋ทฐ์–ด
import SwiftUI
import PDFKit

struct PDFViewerView: View {
    let pdfURL: URL

    var body: some View {
        PDFKitView(url: pdfURL)
            .ignoresSafeArea()
    }
}

struct PDFKitView: UIViewRepresentable {
    let url: URL

    func makeUIView(context: Context) -> PDFView {
        let pdfView = PDFView()

        // PDF ๋ฌธ์„œ ๋กœ๋“œ
        if let document = PDFDocument(url: url) {
            pdfView.document = document
        }

        // ๊ธฐ๋ณธ ์„ค์ •
        pdfView.autoScales = true
        pdfView.displayMode = .singlePageContinuous
        pdfView.displayDirection = .vertical

        return pdfView
    }

    func updateUIView(_ uiView: PDFView, context: Context) {}
}

// ์‚ฌ์šฉ ์˜ˆ์ œ
struct ContentView: View {
    var body: some View {
        if let url = Bundle.main.url(forResource: "sample", withExtension: "pdf") {
            PDFViewerView(pdfURL: url)
        }
    }
}

๐Ÿ“‘ 2. ํŽ˜์ด์ง€ ๋„ค๋น„๊ฒŒ์ด์…˜

PDF ํŽ˜์ด์ง€ ๊ฐ„ ์ด๋™๊ณผ ์ธ๋„ค์ผ ํ‘œ์‹œ๋ฅผ ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

PDFNavigationView.swift โ€” ํŽ˜์ด์ง€ ๋„ค๋น„๊ฒŒ์ด์…˜
import SwiftUI
import PDFKit

struct PDFNavigationView: View {
    let url: URL
    @State private var pdfView = PDFView()
    @State private var currentPage: Int = 1
    @State private var totalPages: Int = 0
    @State private var showThumbnails = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                PDFViewWrapper(
                    pdfView: $pdfView,
                    url: url,
                    currentPage: $currentPage,
                    totalPages: $totalPages
                )

                // ํŽ˜์ด์ง€ ์ปจํŠธ๋กค
                HStack {
                    Button {
                        goToPreviousPage()
                    } label: {
                        Image(systemName: "chevron.left")
                    }
                    .disabled(currentPage <= 1)

                    Text("\(currentPage) / \(totalPages)")
                        .font(.subheadline)
                        .frame(maxWidth: .infinity)

                    Button {
                        goToNextPage()
                    } label: {
                        Image(systemName: "chevron.right")
                    }
                    .disabled(currentPage >= totalPages)
                }
                .padding()
                .background(Color(uiColor: .systemBackground))
                .border(Color.gray.opacity(0.2), width: 1)
            }
            .navigationTitle("PDF ๋ทฐ์–ด")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button {
                        showThumbnails.toggle()
                    } label: {
                        Image(systemName: "square.grid.2x2")
                    }
                }
            }
            .sheet(isPresented: $showThumbnails) {
                PDFThumbnailView(pdfView: pdfView)
            }
        }
    }

    func goToPreviousPage() {
        guard let currentPage = pdfView.currentPage,
              let previousPage = pdfView.document?.page(at: currentPage.pageRef!.pageNumber - 2)
        else { return }
        pdfView.go(to: previousPage)
    }

    func goToNextPage() {
        guard let currentPage = pdfView.currentPage,
              let nextPage = pdfView.document?.page(at: currentPage.pageRef!.pageNumber)
        else { return }
        pdfView.go(to: nextPage)
    }
}

struct PDFViewWrapper: UIViewRepresentable {
    @Binding var pdfView: PDFView
    let url: URL
    @Binding var currentPage: Int
    @Binding var totalPages: Int

    func makeUIView(context: Context) -> PDFView {
        let document = PDFDocument(url: url)
        pdfView.document = document
        totalPages = document?.pageCount ?? 0

        // ํŽ˜์ด์ง€ ๋ณ€๊ฒฝ ๊ฐ์ง€
        NotificationCenter.default.addObserver(
            forName: .PDFViewPageChanged,
            object: pdfView,
            queue: .main
        ) { _ in
            updateCurrentPage()
        }

        return pdfView
    }

    func updateUIView(_ uiView: PDFView, context: Context) {}

    func updateCurrentPage() {
        if let page = pdfView.currentPage,
           let index = pdfView.document?.index(for: page) {
            currentPage = index + 1
        }
    }
}

๐Ÿ” 3. PDF ๊ฒ€์ƒ‰

PDF ๋ฌธ์„œ ๋‚ด์—์„œ ํ…์ŠคํŠธ๋ฅผ ๊ฒ€์ƒ‰ํ•ฉ๋‹ˆ๋‹ค.

PDFSearchView.swift โ€” ํ…์ŠคํŠธ ๊ฒ€์ƒ‰
import SwiftUI
import PDFKit

@Observable
class PDFSearchManager {
    var searchResults: [PDFSelection] = []
    var currentResultIndex: Int = 0

    func search(query: String, in document: PDFDocument?) {
        searchResults.removeAll()

        guard let document = document, !query.isEmpty else { return }

        // ์ „์ฒด ๋ฌธ์„œ ๊ฒ€์ƒ‰
        document.beginFindString(query, withOptions: .caseInsensitive)

        // ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์ˆ˜์ง‘
        NotificationCenter.default.addObserver(
            forName: .PDFDocumentDidFindMatch,
            object: document,
            queue: .main
        ) { notification in
            if let selection = notification.userInfo?["PDFDocumentFoundSelection"] as? PDFSelection {
                self.searchResults.append(selection)
            }
        }
    }

    func goToResult(at index: Int, in pdfView: PDFView) {
        guard index >= 0 && index < searchResults.count else { return }

        let selection = searchResults[index]
        pdfView.setCurrentSelection(selection, animate: true)
        pdfView.go(to: selection)
        currentResultIndex = index
    }
}

struct PDFSearchView: View {
    let url: URL
    @State private var pdfView = PDFView()
    @State private var searchManager = PDFSearchManager()
    @State private var searchQuery = ""
    @State private var isSearching = false

    var body: some View {
        VStack {
            // ๊ฒ€์ƒ‰ ๋ฐ”
            HStack {
                TextField("๊ฒ€์ƒ‰...", text: $searchQuery)
                    .textFieldStyle(.roundedBorder)
                    .onSubmit {
                        performSearch()
                    }

                Button("๊ฒ€์ƒ‰") {
                    performSearch()
                }
            }
            .padding()

            // ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ
            if !searchManager.searchResults.isEmpty {
                HStack {
                    Button {
                        navigateToPrevious()
                    } label: {
                        Image(systemName: "chevron.up")
                    }

                    Text("\(searchManager.currentResultIndex + 1) / \(searchManager.searchResults.count)")
                        .font(.caption)

                    Button {
                        navigateToNext()
                    } label: {
                        Image(systemName: "chevron.down")
                    }
                }
                .padding(.horizontal)
            }

            // PDF ๋ทฐ
            PDFKitView(url: url)
                .onAppear {
                    if let document = PDFDocument(url: url) {
                        pdfView.document = document
                    }
                }
        }
    }

    func performSearch() {
        searchManager.search(query: searchQuery, in: pdfView.document)
        if !searchManager.searchResults.isEmpty {
            searchManager.goToResult(at: 0, in: pdfView)
        }
    }

    func navigateToNext() {
        let nextIndex = (searchManager.currentResultIndex + 1) % searchManager.searchResults.count
        searchManager.goToResult(at: nextIndex, in: pdfView)
    }

    func navigateToPrevious() {
        let prevIndex = (searchManager.currentResultIndex - 1 + searchManager.searchResults.count) % searchManager.searchResults.count
        searchManager.goToResult(at: prevIndex, in: pdfView)
    }
}

โœ๏ธ 4. PDF ์ฃผ์„

PDF์— ํ•˜์ด๋ผ์ดํŠธ, ๋…ธํŠธ, ๊ทธ๋ฆผ ๋“ฑ์˜ ์ฃผ์„์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

PDFAnnotationView.swift โ€” ์ฃผ์„ ์ถ”๊ฐ€
import SwiftUI
import PDFKit

struct PDFAnnotationView: View {
    let url: URL
    @State private var pdfView = PDFView()
    @State private var selectedTool: AnnotationTool = .none

    enum AnnotationTool {
        case none, highlight, note, freeText
    }

    var body: some View {
        VStack {
            // ์ฃผ์„ ํˆด๋ฐ”
            HStack {
                Button {
                    selectedTool = .highlight
                    addHighlight()
                } label: {
                    Label("ํ•˜์ด๋ผ์ดํŠธ", systemImage: "highlighter")
                }

                Button {
                    selectedTool = .note
                    addNote()
                } label: {
                    Label("๋…ธํŠธ", systemImage: "note.text")
                }

                Button {
                    selectedTool = .freeText
                    addFreeText()
                } label: {
                    Label("ํ…์ŠคํŠธ", systemImage: "text.cursor")
                }

                Spacer()

                Button {
                    savePDF()
                } label: {
                    Label("์ €์žฅ", systemImage: "square.and.arrow.down")
                }
            }
            .padding()
            .background(Color(uiColor: .systemBackground))

            // PDF ๋ทฐ
            PDFAnnotatableView(pdfView: $pdfView, url: url)
        }
    }

    func addHighlight() {
        guard let selection = pdfView.currentSelection,
              let page = selection.pages.first
        else { return }

        // ํ•˜์ด๋ผ์ดํŠธ ์ฃผ์„ ์ƒ์„ฑ
        let highlight = PDFAnnotation(
            bounds: selection.bounds(for: page),
            forType: .highlight,
            withProperties: nil
        )
        highlight.color = .UIColor.yellow.withAlphaComponent(0.5)

        page.addAnnotation(highlight)
    }

    func addNote() {
        guard let page = pdfView.currentPage else { return }

        // ๋…ธํŠธ ์ฃผ์„ ์ƒ์„ฑ
        let bounds = CGRect(x: 100, y: 100, width: 20, height: 20)
        let note = PDFAnnotation(bounds: bounds, forType: .text, withProperties: nil)
        note.contents = "์ƒˆ ๋…ธํŠธ"
        note.color = .UIColor.yellow

        page.addAnnotation(note)
    }

    func addFreeText() {
        guard let page = pdfView.currentPage else { return }

        // ์ž์œ  ํ…์ŠคํŠธ ์ฃผ์„
        let bounds = CGRect(x: 100, y: 200, width: 200, height: 50)
        let freeText = PDFAnnotation(bounds: bounds, forType: .freeText, withProperties: nil)
        freeText.contents = "ํ…์ŠคํŠธ ์ž…๋ ฅ"
        freeText.font = UIFont.systemFont(ofSize: 14)
        freeText.fontColor = .UIColor.black

        page.addAnnotation(freeText)
    }

    func savePDF() {
        guard let document = pdfView.document else { return }

        // ๋ฌธ์„œ์— ์ฃผ์„ ์ €์žฅ
        let data = document.dataRepresentation()
        // data๋ฅผ ํŒŒ์ผ๋กœ ์ €์žฅํ•˜๊ฑฐ๋‚˜ ๊ณต์œ 
    }
}

struct PDFAnnotatableView: UIViewRepresentable {
    @Binding var pdfView: PDFView
    let url: URL

    func makeUIView(context: Context) -> PDFView {
        pdfView.document = PDFDocument(url: url)
        pdfView.autoScales = true

        // ์ฃผ์„ ํŽธ์ง‘ ๊ฐ€๋Šฅํ•˜๋„๋ก ์„ค์ •
        pdfView.displayMode = .singlePage

        return pdfView
    }

    func updateUIView(_ uiView: PDFView, context: Context) {}
}

๐Ÿ–ผ๏ธ 5. PDF ์ธ๋„ค์ผ

PDFThumbnailView๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํŽ˜์ด์ง€ ์ธ๋„ค์ผ์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.

PDFThumbnailView.swift โ€” ์ธ๋„ค์ผ ๋ทฐ
import SwiftUI
import PDFKit

struct PDFThumbnailView: View {
    let pdfView: PDFView
    @Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationStack {
            ThumbnailViewWrapper(pdfView: pdfView)
                .navigationTitle("ํŽ˜์ด์ง€")
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("๋‹ซ๊ธฐ") {
                            dismiss()
                        }
                    }
                }
        }
    }
}

struct ThumbnailViewWrapper: UIViewRepresentable {
    let pdfView: PDFView

    func makeUIView(context: Context) -> PDFThumbnailView {
        let thumbnailView = PDFThumbnailView()
        thumbnailView.pdfView = pdfView
        thumbnailView.layoutMode = .vertical
        thumbnailView.thumbnailSize = CGSize(width: 100, height: 150)
        thumbnailView.backgroundColor = .UIColor.systemBackground

        return thumbnailView
    }

    func updateUIView(_ uiView: PDFThumbnailView, context: Context) {}
}

๐Ÿ“ 6. PDF ์ƒ์„ฑ

์ฝ”๋“œ๋กœ ์ƒˆ๋กœ์šด PDF ๋ฌธ์„œ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

PDFGenerator.swift โ€” PDF ์ƒ์„ฑ
import PDFKit
import UIKit

class PDFGenerator {
    // ํ…์ŠคํŠธ๋กœ PDF ์ƒ์„ฑ
    func generatePDF(from text: String) -> PDFDocument? {
        let pdfMetaData = [
            kCGPDFContextCreator: "MyApp",
            kCGPDFContextAuthor: "Author Name",
            kCGPDFContextTitle: "Generated PDF"
        ]

        let format = UIGraphicsPDFRendererFormat()
        format.documentInfo = pdfMetaData as [String: Any]

        let pageRect = CGRect(x: 0, y: 0, width: 612, height: 792) // US Letter
        let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)

        let data = renderer.pdfData { context in
            context.beginPage()

            // ํ…์ŠคํŠธ ๊ทธ๋ฆฌ๊ธฐ
            let paragraphStyle = NSMutableParagraphStyle()
            paragraphStyle.alignment = .natural
            paragraphStyle.lineBreakMode = .byWordWrapping

            let attributes: [NSAttributedString.Key: Any] = [
                .font: UIFont.systemFont(ofSize: 14),
                .paragraphStyle: paragraphStyle
            ]

            let textRect = CGRect(x: 40, y: 40, width: pageRect.width - 80, height: pageRect.height - 80)
            text.draw(in: textRect, withAttributes: attributes)
        }

        return PDFDocument(data: data)
    }

    // ์ด๋ฏธ์ง€๋กœ PDF ์ƒ์„ฑ
    func generatePDF(from images: [UIImage]) -> PDFDocument? {
        let pdfDocument = PDFDocument()

        for (index, image) in images.enumerated() {
            let pageRect = CGRect(origin: .zero, size: image.size)

            let renderer = UIGraphicsPDFRenderer(bounds: pageRect)
            let data = renderer.pdfData { context in
                context.beginPage()
                image.draw(in: pageRect)
            }

            if let page = PDFPage(image: image) {
                pdfDocument.insert(page, at: index)
            }
        }

        return pdfDocument
    }

    // SwiftUI View๋ฅผ PDF๋กœ ๋ณ€ํ™˜
    func generatePDF(from view: UIView) -> PDFDocument? {
        let renderer = UIGraphicsPDFRenderer(bounds: view.bounds)

        let data = renderer.pdfData { context in
            context.beginPage()
            view.layer.render(in: context.cgContext)
        }

        return PDFDocument(data: data)
    }

    // PDF ์ €์žฅ
    func savePDF(_ document: PDFDocument, to url: URL) -> Bool {
        return document.write(to: url)
    }
}

๐Ÿ“ฑ ์ข…ํ•ฉ ์˜ˆ์ œ

PDFReaderApp.swift โ€” PDF ๋ฆฌ๋” ์•ฑ
import SwiftUI
import PDFKit

struct PDFReaderApp: View {
    @State private var pdfView = PDFView()
    @State private var showSearch = false
    @State private var showThumbnails = false
    @State private var displayMode: PDFDisplayMode = .singlePageContinuous
    let url: URL

    var body: some View {
        NavigationStack {
            ZStack {
                PDFReaderView(
                    pdfView: $pdfView,
                    url: url,
                    displayMode: displayMode
                )

                if showSearch {
                    SearchOverlay(pdfView: pdfView, isPresented: $showSearch)
                }
            }
            .navigationTitle("PDF")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItemGroup(placement: .topBarTrailing) {
                    Button {
                        showSearch.toggle()
                    } label: {
                        Image(systemName: "magnifyingglass")
                    }

                    Button {
                        showThumbnails = true
                    } label: {
                        Image(systemName: "square.grid.2x2")
                    }

                    Menu {
                        Button("๋‹จ์ผ ํŽ˜์ด์ง€") {
                            displayMode = .singlePage
                            updateDisplayMode()
                        }
                        Button("์—ฐ์† ์Šคํฌ๋กค") {
                            displayMode = .singlePageContinuous
                            updateDisplayMode()
                        }
                        Button("๋‘ ํŽ˜์ด์ง€") {
                            displayMode = .twoUp
                            updateDisplayMode()
                        }
                    } label: {
                        Image(systemName: "doc.text.viewfinder")
                    }
                }
            }
            .sheet(isPresented: $showThumbnails) {
                PDFThumbnailView(pdfView: pdfView)
            }
        }
    }

    func updateDisplayMode() {
        pdfView.displayMode = displayMode
    }
}

struct PDFReaderView: UIViewRepresentable {
    @Binding var pdfView: PDFView
    let url: URL
    let displayMode: PDFDisplayMode

    func makeUIView(context: Context) -> PDFView {
        pdfView.document = PDFDocument(url: url)
        pdfView.autoScales = true
        pdfView.displayMode = displayMode
        pdfView.displayDirection = .vertical

        return pdfView
    }

    func updateUIView(_ uiView: PDFView, context: Context) {
        uiView.displayMode = displayMode
    }
}

struct SearchOverlay: View {
    let pdfView: PDFView
    @Binding var isPresented: Bool
    @State private var searchText = ""

    var body: some View {
        VStack {
            HStack {
                TextField("๊ฒ€์ƒ‰", text: $searchText)
                    .textFieldStyle(.roundedBorder)
                    .onSubmit {
                        pdfView.document?.beginFindString(searchText, withOptions: .caseInsensitive)
                    }

                Button("๋‹ซ๊ธฐ") {
                    isPresented = false
                    pdfView.document?.cancelFindString()
                }
            }
            .padding()
            .background(Color(uiColor: .systemBackground))
            .shadow(radius: 5)

            Spacer()
        }
    }
}

๐Ÿ’ก HIG ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค์ „ ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ ์„ฑ๋Šฅ ํŒ: ๋Œ€์šฉ๋Ÿ‰ PDF๋Š” PDFView์˜ pageBreakMargins์™€ minScaleFactor/maxScaleFactor๋ฅผ ์กฐ์ •ํ•˜์—ฌ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์„ ์ตœ์ ํ™”ํ•˜์„ธ์š”. ์ธ๋„ค์ผ์€ ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์Šค๋ ˆ๋“œ์—์„œ ์ƒ์„ฑํ•˜๋ฉด UI๊ฐ€ ๋ถ€๋“œ๋Ÿฝ์Šต๋‹ˆ๋‹ค.