When working with Apple's PDFKit in iOS, navigating to a specific selection is a common requirement. However, developers frequently discover that passing a selection's midpoint to PDFDestination(page:at:) scrolls the selection to the top of the screen rather than centering it in the viewport.

Why Does PDFDestination Scroll to the Top?

The standard behavior of PDFDestination(page:at:) in PDFKit is designed to position the specified coordinate at the top-left (or top edge) of the visible viewport. Furthermore, PDFKit uses the Core Graphics coordinate system (where the origin (0,0) is at the bottom-left corner and y increases upwards), while UIKit uses the top-left coordinate system where y increases downwards.

Because pdfView.go(to:) aligns the target point with the top of the visible screen, creating a destination directly at bounds.midY will place the text right at the top edge of your view.

The Solution: Converting Coordinates to the Underlying UIScrollView

The most reliable and responsive way to center any content in a PDFView is to convert the selection's page coordinates into the coordinate space of the underlying UIScrollView and animate the scroll view's contentOffset.

Step-by-Step Implementation

  1. Convert the selection's bounding box from page coordinates to the PDFView's coordinate system using pdfView.convert(_:from:).
  2. Locate the underlying UIScrollView inside the PDFView hierarchy.
  3. Calculate the target offset needed to place the vertical midpoint of the selection at the center of the scroll view.
  4. Clamp the offset to prevent overscrolling past the document boundaries and animate the offset change.

Swift Helper Extension

You can create a reusable extension on PDFView to handle centering any PDFSelection or CGRect:

import PDFKit
import UIKit

extension PDFView {
    /// Centers the specified PDFSelection in the view
    func centerSelection(_ selection: PDFSelection, animated: Bool = true) {
        guard let page = selection.pages.first else { return }
        let pageBounds = selection.bounds(for: page)
        centerRect(pageBounds, on: page, animated: animated)
    }

    /// Centers a given page coordinate rectangle in the view
    func centerRect(_ pageRect: CGRect, on page: PDFPage, animated: Bool = true) {
        guard let scrollView = self.subviews.first(where: { $0 is UIScrollView }) as? UIScrollView else {
            return
        }

        // Convert the page rect to PDFView coordinates
        let viewRect = self.convert(pageRect, from: page)
        
        // Calculate the centered Y offset
        let targetOffsetY = viewRect.midY - (scrollView.bounds.height / 2.0)
        
        // Clamp offset within valid scrollable range
        let minOffsetY = -scrollView.adjustedContentInset.top
        let maxOffsetY = max(0, scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom)
        let clampedOffsetY = min(max(targetOffsetY, minOffsetY), maxOffsetY)

        let targetOffset = CGPoint(x: scrollView.contentOffset.x, y: clampedOffsetY)
        
        scrollView.setContentOffset(targetOffset, animated: animated)
    }
}

Full Updated SwiftUI Example

Here is how to integrate this solution directly into your SwiftUI project:

import SwiftUI
import PDFKit

struct ContentView: View {
    @State private var pdfView: PDFView? = nil

    var body: some View {
        VStack {
            Button("Center Selection") {
                guard let pdfView = pdfView,
                      let selection = pdfView.currentSelection else {
                    return
                }
                pdfView.centerSelection(selection, animated: true)
            }
            .buttonStyle(.borderedProminent)
            .padding()

            PDFViewRepresentable(pdfView: $pdfView)
        }
    }
}

struct PDFViewRepresentable: UIViewRepresentable {
    @Binding var pdfView: PDFView?

    func makeUIView(context: Context) -> PDFView {
        let view = PDFView()
        view.autoScales = true
        view.displayMode = .singlePageContinuous
        view.displayDirection = .vertical

        if let url = Bundle.main.url(forResource: "Lorem Ipsum (long)", withExtension: "pdf") {
            view.document = PDFDocument(url: url)
        }

        DispatchQueue.main.async {
            self.pdfView = view
        }

        return view
    }

    func updateUIView(_ uiView: PDFView, context: Context) {
        // Handle state updates if necessary
    }
}

Key Takeaways

  • Coordinate conversion: PDFView.convert(_:from:) accounts for dynamic zooming (scaleFactor) and page layout modes (single-page or continuous).
  • Clamping boundaries: Always clamp the calculated contentOffset between minOffsetY and maxOffsetY to prevent bounce glitches when selections are near the beginning or end of the document.
  • Animation: Using UIScrollView.setContentOffset(_:animated:) provides smooth native deceleration curves out of the box without needing external UIView.animate wrappers.