Search code examples
iosswiftswiftuiapple-pdfkit

How to set the Anchors Points of a PDF in PDFKit?


I am trying to set the top anchor point of a pdf that is inside of a view with the .ignoresSafeArea() modifier. I would also like it to work on the edges when the phone is in landscape although for simplicity I will only explain what I want for the top. I want it to function like the iOS Files app pdf viewer where when tapped it hides the navigation bars but the top of the pdf stays at the same place, but when you zoom in on the pdf it can fill the whole screen. When you zoom back out the top should return to the same place as before. Here is a simple view to show how it is being used:

@MainActor
struct ContentView: View {
    @State var showBars: Bool = true
    @State var pdfUrl: URL?
    var body: some View {
        NavigationStack {
            GeometryReader { geo in
                ScrollView {
                    TabView {
                        if let url = pdfUrl {
                            PDFViewer(pdfUrl: url)
                                .onTapGesture {
                                    withAnimation {
                                        showBars.toggle()
                                    }
                                }
                        }
                    }
                    .tabViewStyle(.page(indexDisplayMode: .never))
                    .frame(width: geo.size.width, height: geo.size.height)
                }
                .scrollDisabled(true)
            }
            .ignoresSafeArea(edges: !showBars ? .all : [])
        }
        .task {
            pdfUrl = renderPDF()
        }
    }
    
    func renderPDF() -> URL {
        let renderer = ImageRenderer(content: VStack {})
        
        let url = URL.documentsDirectory.appending(path: "samplepdf.pdf")
        
        renderer.render { size, context in
            
            guard let pdf = CGContext(url as CFURL, mediaBox: nil, nil) else {
                return
            }
            
            pdf.beginPDFPage(nil)
            context(pdf)
            pdf.endPDFPage()
            pdf.beginPDFPage(nil)
            context(pdf)
            
            pdf.endPDFPage()
            pdf.closePDF()
        }
        
        return url
    }
}

And here is what my pdfView looks like so far:

struct PDFViewer: View {
    var pdfUrl: URL
    var body: some View {
        PDFSheetView(document: .init(url: pdfUrl))
    }
}

class PDFViewController: UIViewController {
    let document: PDFDocument?
    var pdfView: PDFView!
    
    init(document: PDFDocument?) {
        self.document = document
        
        super.init(nibName: nil, bundle: nil)
    }
    
    override func loadView() {
        let view = PDFView()
        self.view = view
        self.pdfView = view
        
        view.document = document
        view.displayDirection = .vertical
        view.autoScales = true
        view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
    }
    
    required init?(coder: NSCoder) {
        document = nil
        
        super.init(coder: coder)
        return nil
    }
    
    override func viewDidLayoutSubviews() {
        let bounds = view.bounds
        if let document {
            if let page = document.page(at: 0) {
                let pageBounds = page.bounds(for: .mediaBox)
                if bounds.width > 0 && pageBounds.width > 0 {
                    let scaleFactor = bounds.width / pageBounds.width
                    let subtractionFactor = scaleFactor * 0.0125
                    pdfView.minScaleFactor = scaleFactor - subtractionFactor
                }
            }
        }
    }
}

struct PDFSheetView: UIViewControllerRepresentable {
    typealias UIViewControllerType = PDFViewController
    
    let document: PDFDocument?
    
    func makeUIViewController(context: Context) -> PDFViewController {
        
        let controller = PDFViewController(document: document)
        
        return controller
    }
    
    func updateUIViewController(_ uiViewController: PDFViewController, context: Context) {
        
    }
}

Is this possible to do? Like I said before, I want it to function just like the iOS Files app pdf viewer.


Solution

  • You can achieve this by listening to the PDFView notification for scale changes. Then you can use a Binding<Bool> to propagate the change to your ContentView.

    @MainActor
    struct ContentView: View {
        @State var showBars: Bool = true
        @State var pdfUrl: URL?
        var body: some View {
            NavigationStack {
                GeometryReader { geo in
                    ScrollView {
                        TabView {
                            if let url = pdfUrl {
                                // pass the binding on to the subview
                                PDFViewer(pdfUrl: url, showBars: $showBars)
                                    .onTapGesture {
                                        withAnimation {
                                            showBars.toggle()
                                        }
                                    }
                            }
                        }
                        .tabViewStyle(.page(indexDisplayMode: .never))
                        .frame(width: geo.size.width, height: geo.size.height)
                    }
                    .scrollDisabled(true)
                }
                .ignoresSafeArea(edges: !showBars ? .all : [])
                // hide status bar
                .statusBar(hidden: !showBars)
                // smoothly animate changes
                .animation(.default, value: showBars)
            }
            .task {
                pdfUrl = renderPDF()
            }
        }
        ....
    

    This view seems kind of pointless (at least in the context of the question) but nevertheless pass the Binding on again.

    struct PDFViewer: View {
        var pdfUrl: URL
        @Binding var showBars: Bool
        var body: some View {
            PDFSheetView(document: .init(url: pdfUrl), showBars: $showBars)
        }
    }
    

    struct PDFSheetView: UIViewControllerRepresentable {
        typealias UIViewControllerType = PDFViewController
        
        let document: PDFDocument?
        @Binding var showBars: Bool
        
        func makeUIViewController(context: Context) -> PDFViewController {
            // pass the binding on to the ViewController
            let controller = PDFViewController(document: document, showBars: $showBars)
            
            return controller
        }
        
        func updateUIViewController(_ uiViewController: PDFViewController, context: Context) {
            
        }
    }
    

    Inside the ViewController add a Binding and subscribe to the NotificationCenter...

    class PDFViewController: UIViewController {
        let document: PDFDocument?
        var pdfView: PDFView!
        @Binding var showBars: Bool
        
        init(document: PDFDocument?, showBars: Binding<Bool>) {
            self.document = document
            self._showBars = showBars // assign the Binding
            super.init(nibName: nil, bundle: nil)
            // subscribe to the NotificationCenter
            NotificationCenter.default.addObserver(self, selector: #selector(didZoom), name: Notification.Name.PDFViewScaleChanged, object: nil)
        }
    
    // here we calculate the zoom of the PDFView.
    // we need to divide the actual scale by the scale that fits
    // the screen so we get the zoom from the user
    @objc
    private func didZoom(_ sender: Any){
        self.showBars = (pdfView.scaleFactor / pdfView.scaleFactorForSizeToFit) <= 1.1
    }