Building A PDF Viewer With PDFKit

Originally posted 1/16/2018. Updated for Swift 5 and Xcode 10.2.1

PDFKit has been available to Mac developers since 10.4, but with iOS 11 Apple brought its support for viewing, editing, and authoring PDFs to iPhone and iPad. Previously, viewing PDF files meant rendering each page yourself with Core Graphics and Core Text, using an indirect solution like UIWebView or QuickLook, or allowing the user to choose a third-party app that supports PDF viewing with a UIDocumentInteractionController.

In this tutorial we'll see how easy it is to view PDFs, add page thumbnails, and navigate the document using PDFKit. The finished app will look like this:

Starter Project

To get started, download the starter project here. The starter project has all of the storyboard setup and the general outline of our project. The structure is relatively simple, just a single view controller embedded in a navigation controller.

The storyboard has five outlets which you can see in the "Outlets" section at the top of ViewController.swift.

  1. pdfView - PDFView is the main class from PDFKit for displaying PDF content. By default, users can select and copy text and pinch to zoom. The PDFView also maintains page history, so users can jump around in the document and you can support navigating that history.
  2. pdfThumbnailView - PDFThumbnailView automatically generates a set of thumbnails for the PDF's pages. It supports vertical and horizontal layouts and allows users to navigate the document by selecting or panning the thumbnails. We use it here as the viewer's sidebar.
  3. sidebarLeadingConstraint - The NSLayoutConstraint that binds the sidebar to the main view's leading edge. We will use this to show and hide the sidebar.
  4. previousButton - Button used to navigate to the previous page.
  5. nextButton - Button used to navigate to the next page.

Displaying The PDF

We'll start by adding basic support for loading and displaying PDF content. The project uses a sample PDF from Apple called "iOS Deployment Overview for Business". If you look in viewDidLoad, the first method called is setup, which calls the following three functions. Replace those with the following:

func setupPDFView() {
    pdfView.autoScales = true
}

func setupThumbnailView() {
    pdfThumbnailView.pdfView = pdfView
    pdfThumbnailView.thumbnailSize = CGSize(width: thumbnailDimension, height: thumbnailDimension)
    pdfThumbnailView.backgroundColor = sidebarBackgroundColor
}

func loadPDF() {
    guard let url = pdfURL else { return }
    let document = PDFDocument(url: url)
    pdfView.document = document
    resetNavigationButtons()
}
  1. setupPDFView - All we do here is enable auto-scaling so that the PDFView scales the content to fit its bounds.
  2. setupThumbnailView - PDFThumbnailView has a pdfView property that we set as the source of its PDF content. Then we set the size of its thumbnails and a background color. PDFThumbnailView also has a layoutMode property which can be set to vertical or horizontal. Vertical is the default, which is what we want, so we don't need to set it here.
  3. loadPDF - To load the PDF, we first check if the pdfURL declared at the top of the file exists (i.e. we were able to find the PDF in the main bundle), then initialize a PDFDocument with that URL, and finally set the document on the PDFView.

The PDFView can tell us if it can navigate to a previous or next page. Once the document has been loaded, we call resetNavigationButtons which uses this functionality to enable or disable the previous / next buttons accordingly. Replace that function with the following:

func resetNavigationButtons() {
    previousButton.isEnabled = pdfView.canGoToPreviousPage()
    nextButton.isEnabled = pdfView.canGoToNextPage()
}

Build and run the project. At this point you should see the PDF and sidebar with generated thumbnails.

Navigation

If you use the thumbnail sidebar to navigate to the bottom of the document, you'll notice that the "Next" button is still enabled even though we're on the last page, and the "Previous" button is still disabled. Also, neither button does anything yet. Let's fix all of that now.

Responding To Notifications

PDFKit posts notifications as the user interacts with the document. Here we want to know when the page changed so we can reset the state of our navigation buttons. In the "Notifications" section of the code, replace the addObservers function with the following:

func addObservers() {
    NotificationCenter.default.addObserver(self, selector: #selector(resetNavigationButtons), name: .PDFViewPageChanged, object: nil)
}

Here we observe the "PDFViewPageChanged" notification and set the selector to resetNavigationButtons. This means every time the user navigates to a new page we'll have an opportunity to enable / disable the appropriate navigation buttons.

Below, you'll see that removeObservers is already implemented and called from deinit, while addObservers is called from viewDidLoad.

At this point the compiler will complain that resetNavigationButtons is not exposed to Objective-C. To fix this, just add "@objc" to that function.

Navigation Actions

Implementing the previousTapped and nextTapped actions is now trivial.

@IBAction func previousTapped(_ sender: Any) {
    pdfView.goToPreviousPage(sender)
}

@IBAction func nextTapped(_ sender: Any) {
    pdfView.goToNextPage(sender)
}

Because we respond to the "PDFViewPageChanged" notification, we don't even need to reset the state of the navigation buttons ourselves. The notification will be posted and resetNavigationButtons will be called automatically.

Build and run. You should now be able to navigate the document using either the previous / next buttons or the thumbnail view, and the navigation button state should update accordingly.

Showing / Hiding The Sidebar

A feature common to most document viewers with a sidebar is the ability to show and hide it, so the user can choose to use all available screen real estate for the content while reading it. Let's add that now.

The sidebarTapped action already calls toggleSidebar, which we'll replace with the following.

func toggleSidebar() {
    let thumbnailViewWidth = pdfThumbnailView.frame.width
    let screenWidth = UIScreen.main.bounds.width
    let multiplier = thumbnailViewWidth / (screenWidth - thumbnailViewWidth) + 1.0
    let isShowing = sidebarLeadingConstraint.constant == 0
    let scaleFactor = pdfView.scaleFactor
    UIView.animate(withDuration: animationDuration) {
        self.sidebarLeadingConstraint.constant = isShowing ? -thumbnailViewWidth : 0
        self.pdfView.scaleFactor = isShowing ? scaleFactor * multiplier : scaleFactor / multiplier
        self.view.layoutIfNeeded()
    }
}

Here we need to update the PDFView's scale factor manually, because we want to animate the scaling along with the showing / hiding of the view.

We start by getting the ratio of the thumbnail view width to the PDFView when the sidebar is showing. This will be the multiplier we use to adjust the scale factor. Then we get the current state. We "hide" the thumbnail sidebar by moving it offscreen, so to check whether or not it is currently showing we check if the leading constraint's constant is 0. Then we get the current scale factor on the PDFView.

In the animation block, we first adjust the leading constraint to move the thumbnail view on / off screen. Then depending on whether the sidebar is showing, we either multiply or divide the current scale factor by the multiplier to toggle it.

If you build and run, you should now be able to show / hide the sidebar, and the PDF scale will animate to fit the size of the view.

Programmatically Scaling To Fit

Right now if you pinch to zoom in and out on the PDFView, there's no way to get back to the default view, which used autoscaling to fit the content to the view. Also, if you rotate the device to landscape when the content fits in portrait, the content won't be scaled to fit in landscape. We'll fix that in this section.

The resetTapped action already calls scalePDFViewToFit, which we'll now fill in with the following:

UIView.animate(withDuration: animationDuration) {
    self.pdfView.scaleFactor = self.pdfView.scaleFactorForSizeToFit
    self.view.layoutIfNeeded()
}

As you can see, PDFKit makes this easy with the scaleFactorForSizeToFit property. Now to respond to device orientation changes, all we need to do is call the scalePDFViewToFit function during the orientation transition. Replace the viewWillTransition override in the "Lifecycle" section with the following:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
    coordinator.animate(alongsideTransition: { _ in
        self.scalePDFViewToFit()
    }, completion: nil)
}

As we have seen, building a fully functioning PDF viewer is easy with PDFKit. In this tutorial we've only scratched the surface of what the framework can do. PDFKit has rich support for authoring, editing, annotating, and watermarking PDF documents in addition to viewing and navigation. And with the new iPad Pros, there are many great use cases to support!

Next Steps