📘

【SwiftUI】PDFの表示方法

2024/01/26に公開

Case.1 Imageを使用する方法

最も手軽な方法はAssets.xcassetsにPDFを追加し、Imageを使用する方法です。

ただこの方法には欠点があり、複数ページをもつPDFでは最初のページしか表示することができません。

struct ContentView: View {
    var body: some View {
        Image("Single PDF")
            .resizable()
            .scaledToFit()
            .frame(width: 350, height: 600)
//        Image("Multiple PDF")
//            .resizable()
//            .scaledToFit()
//            .frame(width: 350, height: 600)
    }
}

Case.2 PDFViewを使う方法

二つ目の方法はPDFKitを用いる方法です。この方法は複数ページをもつPDFにも対応しています。

まずは前回と異なり、ターゲットフォルダ直下にPDFを入れて使えるようにしてください。

独自でPDFKitViewというUIViewRepresentableに準拠したViewを作成します。中身としてはmakeUIView内でPDFViewを作成し、documentプロパティを設定後リターンするといったものになっています。大抵の場合autoScalesをtrueとしておけばいいと思います。

import PDFKit

struct ContentView: View {
    var body: some View {
        PDFKitView(urlString: "Multiple PDF")
    }
}

struct PDFKitView: UIViewRepresentable {
    private let url: URL

    init(urlString: String) {
        self.url = Bundle.main.url(forResource: urlString, withExtension: "pdf")!
    }

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

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

おまけ1

好みに応じて使われそうなプロパティとしてはこの辺りではないかと思います。

pdfView.displayMode = .twoUpContinuous
pdfView.backgroundColor = .brown
pdfView.displayDirection = .horizontal
pdfView.displaysPageBreaks = false
pdfView.pageBreakMargins = .init(top: 20, left: 20, bottom: 20, right: 20)

おまけ2

ボタンで表示するPDFのページをコントロールできるようにしてみました。

@Binding var currentPageIndex: Intのところを@State private var currentPageIndex: Intとすればもう少しうまいこと書けるのではと思われるかもしれませんが、updateUIViewで状態管理を担うプロパティの値を変更はできません。ランタイムワーニングが出ます。

import PDFKit

struct ContentView: View {
    var body: some View {
        PDFViewer(urlString: "Multiple PDF")
    }
}

struct PDFViewer: View {
    @State private var currentPageIndex = 0
    private let document: PDFDocument?

    init(urlString: String) {
        guard let url = Bundle.main.url(forResource: urlString, withExtension: "pdf"), let document = PDFDocument(url: url) else {
            self.document = nil
            return
        }
        self.document = document
    }

    var body: some View {
        VStack {
            if let document = self.document {
                PDFKitView(
                    document: document,
                    currentPageIndex: $currentPageIndex
                )
            } else {
                Text("エラーが発生しました。")
                    .font(.largeTitle.bold())
            }
        }
        .overlay(alignment: .bottom) {
            if let document {
                HStack {
                    Button(action: {
                        currentPageIndex -= 1
                    }, label: {
                        Image(systemName: "arrowshape.backward.circle.fill")
                            .resizable()
                            .frame(width: 50, height: 50)
                            .scaledToFit()
                    })
                    .disabled(!(currentPageIndex > 0))
                    Spacer().frame(width: 30)
                    Button(action: {
                        currentPageIndex += 1
                    }, label: {
                        Image(systemName: "arrowshape.forward.circle.fill")
                            .resizable()
                            .frame(width: 50, height: 50)
                            .scaledToFit()
                    })
                    .disabled(!(currentPageIndex < document.pageCount-1))
                }
            }
        }
    }
}

struct PDFKitView: UIViewRepresentable {
    let document: PDFDocument
    @Binding var currentPageIndex: Int

    func makeUIView(context: Context) -> PDFView {
        let pdfView = PDFView()
        pdfView.document = self.document
        pdfView.autoScales = true
        pdfView.displayMode = .singlePage
        pdfView.backgroundColor = .clear
        return pdfView
    }

    func updateUIView(_ uiView: PDFView, context: Context) {
        guard let document = uiView.document,
              currentPageIndex >= 0,
              currentPageIndex < document.pageCount,
              let page = document.page(at: currentPageIndex)
        else { fatalError() }
        uiView.go(to: page)
    }
}

おまけ3

今回のパターンを比較できるようにリストを用いて実装してみました。

import SwiftUI
import PDFKit

enum PDF {
    static let single = "Single PDF"
    static let multiple = "Multiple PDF"
}

struct ContentView: View {
    var body: some View {
        NavigationStack {
            List {
                Section {
                    ForEach([PDF.single, PDF.multiple], id: \.self) { fileName  in
                        NavigationLink(fileName) {
                            Image(fileName)
                                .resizable()
                                .scaledToFit()
                        }
                    }
                } header: {
                    Text("Use Image Instance")
                        .textCase(.none)
                }
                Section {
                    ForEach([PDF.single, PDF.multiple], id: \.self) { fileName in
                        NavigationLink(fileName + " (Vertical)") {
                            PDFKitView1_Vertical(urlString: fileName)
                                .navigationTitle("Vertical")
                                .navigationBarTitleDisplayMode(.inline)
                        }
                        NavigationLink(fileName + " (Horizontal)") {
                            PDFKitView1_Horizontal(urlString: fileName)
                                .navigationTitle("Horizontal")
                                .navigationBarTitleDisplayMode(.inline)
                        }
                    }
                } header: {
                    Text("Use PDFKitView1 Instance")
                        .textCase(.none)
                }
                Section {
                    ForEach([PDF.single, PDF.multiple], id: \.self) { fileName in
                        NavigationLink(fileName) {
                            PDFViewer(fileName: fileName)
                        }
                    }
                } header: {
                    Text("Use PDFKitView2 Instance")
                        .textCase(.none)
                }
            }
            .navigationTitle("Documents")
        }
    }
}

struct PDFKitView1_Vertical: UIViewRepresentable {
    private let url: URL

    init(urlString: String) {
        self.url = Bundle.main.url(forResource: urlString, withExtension: "pdf")!
    }

    func makeUIView(context: Context) -> PDFView {
        let pdfView = PDFView()
        pdfView.document = PDFDocument(url: url)
        pdfView.autoScales = true
        pdfView.displayDirection = .vertical // default
        return pdfView
    }

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

struct PDFKitView1_Horizontal: UIViewRepresentable {
    private let url: URL

    init(urlString: String) {
        self.url = Bundle.main.url(forResource: urlString, withExtension: "pdf")!
    }

    func makeUIView(context: Context) -> PDFView {
        let pdfView = PDFView()
        pdfView.document = PDFDocument(url: url)
        pdfView.autoScales = true
        pdfView.displayDirection = .horizontal
        return pdfView
    }

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

struct PDFViewer: View {
    @State private var currentPageIndex = 0
    private let document: PDFDocument?

    init(fileName: String) {
        guard let url = Bundle.main.url(forResource: fileName, withExtension: "pdf"), let document = PDFDocument(url: url) else {
            self.document = nil
            return
        }
        self.document = document
    }

    var body: some View {
        VStack {
            if let document = self.document {
                PDFKitView2(
                    document: document,
                    currentPageIndex: $currentPageIndex
                )
            } else {
                Text("エラーが発生しました。")
                    .font(.largeTitle.bold())
            }
        }
        .sensoryFeedback(.success, trigger: currentPageIndex)
        .padding(.bottom, 100)
        .overlay(alignment: .bottom) {
            if let document {
                HStack {
                    Button(action: {
                        currentPageIndex -= 1
                    }, label: {
                        Image(systemName: "arrowshape.backward.circle.fill")
                            .resizable()
                            .frame(width: 50, height: 50)
                            .scaledToFit()
                    })
                    .disabled(!(currentPageIndex > 0))
                    Spacer().frame(width: 30)
                    Button(action: {
                        currentPageIndex += 1
                    }, label: {
                        Image(systemName: "arrowshape.forward.circle.fill")
                            .resizable()
                            .frame(width: 50, height: 50)
                            .scaledToFit()
                    })
                    .disabled(!(currentPageIndex < document.pageCount-1))
                }
            }
        }
    }
}

struct PDFKitView2: UIViewRepresentable {
    let document: PDFDocument
    @Binding var currentPageIndex: Int

    func makeUIView(context: Context) -> PDFView {
        let pdfView = PDFView()
        pdfView.document = self.document
        pdfView.autoScales = true
        pdfView.displayMode = .singlePage
        pdfView.backgroundColor = .clear
        return pdfView
    }

    func updateUIView(_ uiView: PDFView, context: Context) {
        guard let document = uiView.document,
              currentPageIndex >= 0,
              currentPageIndex < document.pageCount,
              let page = document.page(at: currentPageIndex)
        else { fatalError() }
        uiView.go(to: page)
    }
}

#Preview {
    ContentView()
}

Discussion