๐ PDFKit
PDF ํ์, ์ฃผ์, ๊ฒ์, ์์ฑ์ ์ํ ์์ ํ ์๋ฃจ์
โจ PDFKit์ด๋?
PDFKit์ PDF ๋ฌธ์๋ฅผ ํ์, ํธ์ง, ์์ฑํ ์ ์๋ ๊ฐ๋ ฅํ ํ๋ ์์ํฌ์ ๋๋ค. ํ์ด์ง ๋ค๋น๊ฒ์ด์ , ํ๋/์ถ์, ๊ฒ์, ์ฃผ์ ์ถ๊ฐ, ์ธ๋ค์ผ ํ์ ๋ฑ PDF ์์ ์ ํ์ํ ๋ชจ๋ ๊ธฐ๋ฅ์ ์ ๊ณตํฉ๋๋ค. SwiftUI์ UIKit ๋ชจ๋์์ ์ฌ์ฉํ ์ ์์ผ๋ฉฐ, ๋ฌธ์ ๋ทฐ์ด ์ฑ, ์ ์์ฑ ๋ฆฌ๋, ์ฃผ์ ๋๊ตฌ ๋ฑ์ ๋ง๋ค ๋ ํ์์ ์ ๋๋ค.
๐ฏ 1. PDF ํ์ ๊ธฐ๋ณธ
PDFView๋ฅผ ์ฌ์ฉํ์ฌ 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 ํ์ด์ง ๊ฐ ์ด๋๊ณผ ์ธ๋ค์ผ ํ์๋ฅผ ๊ตฌํํฉ๋๋ค.
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 ๋ฌธ์ ๋ด์์ ํ ์คํธ๋ฅผ ๊ฒ์ํฉ๋๋ค.
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์ ํ์ด๋ผ์ดํธ, ๋ ธํธ, ๊ทธ๋ฆผ ๋ฑ์ ์ฃผ์์ ์ถ๊ฐํฉ๋๋ค.
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๋ฅผ ์ฌ์ฉํ์ฌ ํ์ด์ง ์ธ๋ค์ผ์ ํ์ํฉ๋๋ค.
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 ๋ฌธ์๋ฅผ ์์ฑํฉ๋๋ค.
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) } }
๐ฑ ์ข ํฉ ์์
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 ๊ฐ์ด๋๋ผ์ธ
- ๊ฐ๋ ์ฑ: ์๋ ํฌ๊ธฐ ์กฐ์ (autoScales)๋ก ์ฝ๊ธฐ ํธํ ํฌ๊ธฐ ์ ๊ณต
- ๋ค๋น๊ฒ์ด์ : ๋ช ํํ ํ์ด์ง ํ์ ๋ฐ ์ด๋ ์ปจํธ๋กค
- ๊ฒ์: ๋น ๋ฅธ ํ ์คํธ ๊ฒ์๊ณผ ๊ฒฐ๊ณผ ํ์ด๋ผ์ดํ
- ์ฃผ์: ์ง๊ด์ ์ธ ์ฃผ์ ๋๊ตฌ ์ ๊ณต
- ์ฑ๋ฅ: ๋์ฉ๋ PDF๋ ํ์ด์ง๋ณ ๋ก๋ฉ ๊ณ ๋ ค
- ์ ๊ทผ์ฑ: VoiceOver ์ง์ ๋ฐ ํ ์คํธ ์ ํ ๊ฐ๋ฅ
๐ฏ ์ค์ ํ์ฉ
- ๋ฌธ์ ๋ทฐ์ด: ๊ณ์ฝ์, ๋ณด๊ณ ์ ์ด๋ ์ฑ
- ์ ์์ฑ ๋ฆฌ๋: PDF ์ ์์ฑ ๋ฆฌ๋
- ์ฃผ์ ๋๊ตฌ: ํ์ต์ฉ PDF ๋งํน ์ฑ
- ๋ฆฌํฌํธ ์์ฑ: ๋ฐ์ดํฐ๋ฅผ PDF ๋ฆฌํฌํธ๋ก ๋ณํ
- ๋ฌธ์ ์๋ช : ์ ์ ์๋ช ์ถ๊ฐ ์ฑ
๐ ๋ ์์๋ณด๊ธฐ
PDFView์ pageBreakMargins์ minScaleFactor/maxScaleFactor๋ฅผ ์กฐ์ ํ์ฌ ๋ฉ๋ชจ๋ฆฌ ์ฌ์ฉ๋์ ์ต์ ํํ์ธ์. ์ธ๋ค์ผ์ ๋ฐฑ๊ทธ๋ผ์ด๋ ์ค๋ ๋์์ ์์ฑํ๋ฉด UI๊ฐ ๋ถ๋๋ฝ์ต๋๋ค.