DEV Community

Artem Poluektov
Artem Poluektov

Posted on

iOS PDFKit Ink Annotations Tutorial

This is my first article about Apple’s PDFKit. We’ll start with PDFKit basics and will create first Ink annotations in the end of this tutorial.

  • The second is about PencilKit, Text annotations and auto-saving.
  • The third article is about creating PDF document on devices, and inserting and removing pages.

My team recently started a new project: to develop a new iOS app with a built-in PDF Viewer.

The key feature of this viewer was the ability to add annotations to a PDF file with a finger or Apple Pencil. Of course, we understood it wouldn’t be an easy task, but we never imagined quite how challenging it would be.

At first sight, PDFKit looks like any other Apple’s framework included in iOS SDK. It includes views for PDF documents and thumbnails with built-in gestures support and lot of animations. It actually seems to be "magical" framework to solve our task in just a few lines of code.

However, I’d spent a lot of time making drawings, touches, and annotations work as expected. We also found a few bugs, which lead to crashes (inside the Framework), and lack of documentation and tutorials or examples.
That’s why I made this tutorial for you!


Updated for iOS 15

This tutorial was updated to iOS 15, you’ll also find some useful information about PencilKit, PKCanvasView and PKToolPicker introduced with iOS 13 in the second part, as well as how to add text annotations.
However, there were no major changes in PDFKit framework since iOS 13.


The task

The task was pretty simple:

  • Download a PDF file to the device.
  • View a PDF document and allow the user to navigate between pages.
  • Add ink annotations to a PDF with the user’s finger or an Apple pencil
  • Save a PDF document and upload it to the server.

Looks easy, doesn’t it? We don’t need to teach you how to use Alamofire for downloading and uploading files — we’ll go straight to PDFKit.

Note: In this tutorial, I’m going to build an iPad app, but PDFKit also works for iPhone.


PDFView

PDFView is the key object in PDFKit. It’s a subclass of UIView, and its main purpose is to display a PDF Document. To add one to your ViewController’s view, you could use either Storyboard or in-code initialization. Personally, I prefer Storyboard to quickly build an example project.

To add a PDFView to your ViewController’s view

  • Simply add new UIView
  • Setup constraints (I’m going to leave some space on the left for thumbnails view)
  • Go to the Inspector panel (on the right side), select Identity tab and enter the class name PDFView.

Adding PDFView to your ViewController’s view

Now we can set up PDFView:
Create @IBOutlet to your ViewController’s class using Interface Builder
Don’t forget to import PDFKit
Here’s my PDFView setup code:

pdfView.displayDirection = .vertical
pdfView.usePageViewController(true)
pdfView.pageBreakMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
pdfView.autoScales = true
pdfView.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
Enter fullscreen mode Exit fullscreen mode

The first line is pretty simple — your PDFView can have either a vertical or horizontal scrolling direction.

The second line is very useful. By default, PDFView uses the built-in UIScrollView to enable a continuous scroll across the whole document. However, you may activate PageViewController mode, so that the PDFView shows only one page at the screen. Of course, zooming is supported and enabled by default.

I found that the .autoScales setting contains a bug. It doesn’t work on iPad when the screen rotates. To solve this issue, you have to add this call:

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
  pdfView.autoScales = true
}
Enter fullscreen mode Exit fullscreen mode

Another method with unexpected behavior is .backgroundColor. It works in code only and doesn’t work when you’re trying to set it in Interface Builder. I spent a long time trying to understand what I had done wrong!

Finally, I was able to find the reason: Calling pdfView.document = ... resets PDFView’s background color to default. So, call pdfView.backgroundColor = ... after pdfView.document = ....
After completing PDFView setup, let’s add an example PDF document to your project (drag and drop it to the left panel):

Image description

Create PDFDocument and add it to your PDFView:

guard let path = Bundle.main.url(forResource: "YOUR_FILE_NAME", withExtension: "pdf") else { return }
pdfView.document = PDFDocument(url: path)
Enter fullscreen mode Exit fullscreen mode

That’s easy!

Now we have our app up and running with these out-of-the-box features:

  • Zooming with the two-finger gesture.
  • Scrolling.
  • Long-pressure gesture to call the copy menu.
  • ThumbnailView support.

This is what my viewDidLoad method looks like after this step:

override func viewDidLoad() {
super.viewDidLoad()
// Setup PDF View
pdfView.displayDirection = .vertical
pdfView.usePageViewController(true)
pdfView.pageBreakMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
pdfView.autoScales = true
// Load PDF File
guard let path = Bundle.main.url(forResource: "241_introducing_pdfkit_on_ios", withExtension: "pdf") else {
print("file not found")
return
}
pdfView.document = PDFDocument(url: path)
// Set background color after setting new document
pdfView.backgroundColor = .systemBackground
}
Enter fullscreen mode Exit fullscreen mode

Note: It’s not the best idea to put so many lines of code in your viewDidLoad, but it’s a tutorial, so let’s just leave it!


PDFThumbnailView

Creating a thumbnail view is easy. Like we just did in the previous step:

  • Add new UIView to your ViewController’s view (not PDFView’s subview).
  • Setup constraints.
  • Set PDFThumbnailView as a class name in the Identity Inspector tab in the right panel.
  • Create @IBOutlet to your ViewController’s code.

Image description

Use this code in your viewDidLoad method to set up your new Thumbnail View:

thumbnailView.pdfView = pdfView
thumbnailView.thumbnailSize = CGSize(width: 100, height: 100)
thumbnailView.layoutMode = .vertical
thumbnailView.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
Enter fullscreen mode Exit fullscreen mode

This code is clear. I have to mention that PDFThumbnailView, like PDFView, doesn’t respect the .backgroundColor Interface Building setting. However, it stores color you set, so you don’t have to reset it every time when calling pdfView.document = ....

Unfortunately, we had crashes with PDFThumbnailView on iOS 12 when the user tried to add annotations. In one case user was able to draw for an hour with no crashes but in other cases, the app crashed after the first annotation. It seems to have been fixed in iOS 13.


Receiving Touch Events

Initially, to implement user input, I tried to add transparent UIView as pdfView's subview. I found that transparent views do not receive touch events.

Then I set alpha to 0.01, the minimal alpha for view to receive touches. However, I still had an issue with zooming and scrolling gestures. I wanted my transparent view to receive one-finger gestures and forward other (two-finger) gestures to its parent view ( pdfView ). I finally found that this approach just wouldn’t work.

So, finally, I implemented my own custom gesture recognizer. It only works with one-finger gestures and fails if the gesture has multiple fingers. Here is the code:

class DrawingGestureRecognizer: UIGestureRecognizer {
  weak var drawingDelegate: DrawingGestureRecognizerDelegate?
  override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
    if let touch = touches.first,
    touch.type == .pencil,
    let numberOfTouches = event?.allTouches?.count,
    numberOfTouches == 1 {
      state = .began
      let location = touch.location(in: self.view)
      drawingDelegate?.gestureRecognizerBegan(location)
    } else {
      state = .failed
    }
  }
  override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
    state = .changed
    guard let location = touches.first?.location(in: self.view) else { return }
    drawingDelegate?.gestureRecognizerMoved(location)
  }
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    guard let location = touches.first?.location(in: self.view) else  {
      state = .ended
      return
    }
    drawingDelegate?.gestureRecognizerEnded(location)
    state = .ended
  }
  override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
    state = .failed
  }
}
Enter fullscreen mode Exit fullscreen mode

First, I implemented the touchesBegan method, where it analyzes the touch it received.

If it’s Apple Pencil and a one-finger gesture, I set the gesture recognizer state to .began and continue receiving touch events. You could comment the Pencil line to make this code work on iOS Simulator. You cannot simulate Pencil on your Mac, unfortunately. If it’s multi-touch gesture, I set gesture recognizer state to .failed, so that other pdfView's gesture recognizers work.

In touchesBegan I’m also notifying my delegate that we received a new touch event so it could create the new PDFAnnotation. In the touchesMoved method I’m only notifying the delegate about new touches locations.

Finally, in the touchesEnded method, I’m sending the final touch location and notifying my delegate that touch has ended.
Here’s the protocol reference — it’s quite simple:

protocol DrawingGestureRecognizerDelegate: class {
func gestureRecognizerBegan(_ location: CGPoint)
func gestureRecognizerMoved(_ location: CGPoint)
func gestureRecognizerEnded(_ location: CGPoint)
}
Enter fullscreen mode Exit fullscreen mode

Add this gesture recognizer to your PDFView by adding this code to your ViewController’s viewDidLoad method:

let pdfDrawingGestureRecognizer = DrawingGestureRecognizer()
pdfView.addGestureRecognizer(pdfDrawingGestureRecognizer)
Enter fullscreen mode Exit fullscreen mode

Coalesced and Predicted Touches

Each touch event (UIEvent) from our gesture recognizer also has coalesced and predicted touch arrays. Due to different device hardware and to improve performance, only some touch events are being received by the gesture recognizer in real-time.

If your app needs better precision, you could access all touches by checking UIEvent's coalescedTouches property. iOS would also try to predict the user’s finger or pencil movement and create a predictedTouches array for each UIEvent.

According to Apple’s documentation, you could store coalescedTouches and use them for drawing. However, you must remove any drawing used data from predictedTouches when you receive the next touch event.

We tried to forward the gesture recognizer’s delegate not only the location of current touch, but also the arrays of coalesced and predicted touches. However, it caused performance issues, so in the final implementation we decided to stuck with just regular touch events and skipped coalesced and predicted touches. In our case, it didn’t affect user experience in the end.


Drawing Implementation

After we created the gesture recognizer, we need to implement drawing. I’ll try to follow the single-responsibility principle and create another class to handle drawing. Let’s call it PDFDrawer:

class PDFDrawer {
  weak var pdfView: PDFView!
  private var path: UIBezierPath?
  private var currentAnnotation : PDFAnnotation?
  private var currentPage: PDFPage?
}
Enter fullscreen mode Exit fullscreen mode

Our PDFDrawer should hold a reference to PDFView, to which we add our annotations. Path is used to store all touch events in a single gesture. CurrentAnnotation reference is also required because we add new points to our path whenever the user moves their Apple Pencil, so we need to update it. CurrentPage is also required for cases where a user starts a gesture on one page and then accidentally moves to the next page.

Here I should explain how PDFDocument works. The document contains pages which are represented by the PDFPage class. Each page contains its own annotations of class PDFAnnotation. So, in the case where a user starts a gesture on one page and moves touches to another page, we have to keep drawing on the initial page.

PDFDrawer is gesture recogniser’s delegate, so we need to implement three methods of DrawingGestureRecognizerDelegate protocol. First, one is called on the user’s first touch:

func gestureRecognizerBegan(_ location: CGPoint) {
  guard let page = pdfView.page(for: location, nearest: true) else { return }
  currentPage = page
  let convertedPoint = pdfView.convert(location, to: currentPage!)
  path = UIBezierPath()
  path?.move(to: convertedPoint)
}
Enter fullscreen mode Exit fullscreen mode

Here we storing current page reference and creating new UIBezierPath for the new drawing.

When the user moves their finger or Pencil the following method is called:

func gestureRecognizerMoved(_ location: CGPoint) {
  guard let page = currentPage else { return }
  let convertedPoint = pdfView.convert(location, to: page)
  path?.addLine(to: convertedPoint)
  path?.move(to: convertedPoint)
  drawAnnotation(onPage: page)
}
Enter fullscreen mode Exit fullscreen mode

This checks if the current page reference exists, adds a new point to our path, and adds drawing annotation. I will provide the drawAnnotation method implementation later.

When the user finishes their touch, exactly the same code is called. Or at least, it’s the same at this stage of the tutorial, but we will add some specific code later to implement an eraser.

func gestureRecognizerEnded(_ location: CGPoint) {
  guard let page = currentPage else { return }
  let convertedPoint = pdfView.convert(location, to: page)
  path?.addLine(to: convertedPoint)
  path?.move(to: convertedPoint)
  drawAnnotation(onPage: page)
  currentAnnotation = nil
}
Enter fullscreen mode Exit fullscreen mode

Easy, right? Initially, I tried to reuse one annotation across began, moved, and ended events. I added a new UIBezierPath to the current annotation every time by calling .add(UIBezierPath) method of PDFAnnotation. However, we found that the app crashes somewhere inside the PDFKit framework.

So instead of calling .add on one annotation, I create a new annotation every time touch moves and remove the old one. This (adding and removing) is visible on iOS Simulator but works smoothly on a real iPad. This is the only solution I’ve found that does not causes crashes inside the PDFKit Framework.

Here’s my code to create the annotation:

private func createAnnotation(path: UIBezierPath, page: PDFPage) -> PDFAnnotation {
  let border = PDFBorder()
  border.lineWidth = 5.0 // Set your line width here
  let annotation = PDFAnnotation(bounds: page.bounds(for: pdfView.displayBox), forType: .ink, withProperties: nil)
  annotation.color = .red
  annotation.border = border
  annotation.add(path)
  return annotation
}
private func drawAnnotation(onPage: PDFPage) {
  guard let path = path else { return }
  let annotation = createAnnotation(path: path, page: onPage)
  if let _ = currentAnnotation {
    currentAnnotation!.page?.removeAnnotation(currentAnnotation!)
  }
  onPage.addAnnotation(annotation)
  currentAnnotation = annotation
}
Enter fullscreen mode Exit fullscreen mode

Another issue that forced us to use this solution is the feature request from our client. They wanted not only drawing with full color but also to be able to draw with transparent color (with alpha component).

The default PDFAnnotation with few paths looked really bad. Because it consisted of few transparent paths, the points where one path ends and the other begins were brighter than lines because of intersections. Because all the drawing is performed inside PDFKit, we’re unable to change that. That’s why we used our solution instead.

Image description

Finally, to make things work, we need to create a PDFDrawer property in our ViewController:

private let pdfDrawer = PDFDrawer()

Enter fullscreen mode Exit fullscreen mode

And add this setup calls, for example, in viewDidLoad method:

pdfDrawingGestureRecognizer.drawingDelegate = pdfDrawer
pdfDrawer.pdfView = pdfView
Enter fullscreen mode Exit fullscreen mode

Long Pressure Gesture Recognizer

As I mentioned above, PDFView contains a lot of built-in gesture recognizers. In some rare cases, our custom gesture recognizer conflicted with built-in long-pressure gesture recognizer used to copy action. We haven’t found any setting of PDFView to disable it, so we had to implement this subclass of PDFView:

import UIKit
import PDFKit
class NonSelectablePDFView: PDFView {
// Disable selection
  override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
    return false
  }
  override func addGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
    if gestureRecognizer is UILongPressGestureRecognizer {
      gestureRecognizer.isEnabled = false
    }
    super.addGestureRecognizer(gestureRecognizer)
  }
}
Enter fullscreen mode Exit fullscreen mode

Custom PDFAnnotations

We also experimented with custom PDFAnnotations. As I mentioned above, we needed to remove and add new PDFAnnotation after receiving new touch events.

We tried to use a single annotation from the beginning of the gesture to its end, to improve drawing performance. So, we created a custom annotation class with path property and overridden draw() method.

However, we were unable to force PDFKit to redraw the annotation every time we modified the path because PDFAnnotation is not UIView and does not have any methods for that.

Finally, we decided to add and remove the same annotation from the page every time we received a new touch event (without creating a new one). With custom draw method we achieved visual drawing performance increase noticeably improving user experience. Here’s the draw() method:

class DrawingAnnotation: PDFAnnotation {
  public var path = UIBezierPath()
  override func draw(with box: PDFDisplayBox, in context: CGContext) {
    UIGraphicsPushContext(context)
    context.saveGState()
    color.set()
    path.lineWidth = border?.lineWidth
    path.stroke() // this is actual drawing call
    context.restoreGState()
    UIGraphicsPopContext()
  }
}
Enter fullscreen mode Exit fullscreen mode

To eliminate drawing artefacts we add some extra settings to the draw() method:

path.lineJoinStyle = .round
path.lineCapStyle = .round
Enter fullscreen mode Exit fullscreen mode

To make this method thread-safe we replaced path the copy inside the draw() method:

override func draw(with box: PDFDisplayBox, in context: CGContext) {
  let pathCopy = path.copy() as! UIBezierPath
  UIGraphicsPushContext(context)
  context.saveGState()
  context.setShouldAntialias(true)
  color.set()
  pathCopy.lineJoinStyle = .round
  pathCopy.lineCapStyle = .round
  pathCopy.lineWidth = border?.lineWidth ?? 1.0
  pathCopy.stroke()
  context.restoreGState()
  UIGraphicsPopContext()
}
Enter fullscreen mode Exit fullscreen mode

Custom Annotations and Performance

After the first release, we found another performance issue. Users experienced freezes on scrolling and zooming PDF documents with our custom annotations. Some even crashed due to memory warnings — even in-system and third-party apps.

To fix this issue and maintain a great drawing experience we decided to use custom annotation while the user is drawing and replace it with the system PDFAnnotation right after the user finishes touch. We also added new bounds calculation mechanics to minimize final annotation’s bounds size, reducing memory usage and improving drawing performance. I’ll remind you that we used whole page size as the bounds for our custom annotation during the drawing process — the user can draw over the whole page, quickly and smoothly.

Updated PDFDrawer gestureRecognizerEnded method:

func gestureRecognizerEnded(_ location: CGPoint) {
  guard let page = currentPage else { return }
  let convertedPoint = pdfView.convert(location, to: page)
  // Erasing
  if drawingTool == .eraser {
    removeAnnotationAtPoint(point: convertedPoint, page: page)
    return
  }
  guard let _ = currentAnnotation else { return }
  path?.addLine(to: convertedPoint)
  path?.move(to: convertedPoint)
  // Final annotation
  page.removeAnnotation(currentAnnotation!)
  currentAnnotation = nil
  createFinalAnnotation(path: path!, page: page)
}
Enter fullscreen mode Exit fullscreen mode

In this method we’re still updating path, then removing the old (custom) annotation and creating final (system) annotation:

private func createFinalAnnotation(path: UIBezierPath, page: PDFPage) -> PDFAnnotation {
  let border = PDFBorder()
  border.lineWidth = drawingTool.width
  let bounds = CGRect(x: path.bounds.origin.x - 5,
                      y: path.bounds.origin.y - 5,
                  width: path.bounds.size.width + 10,
                 height: path.bounds.size.height + 10)
  var signingPathCentered = UIBezierPath()
  signingPathCentered.cgPath = path.cgPath
  signingPathCentered.moveCenter(to: bounds.center)
  let annotation = PDFAnnotation(bounds: bounds, forType: .ink, withProperties: nil)
  annotation.color = color.withAlphaComponent(drawingTool.alpha)
  annotation.border = border
  annotation.add(signingPathCentered)
  page.addAnnotation(annotation)
  return annotation
}
Enter fullscreen mode Exit fullscreen mode

That’s it. Great performance during drawing and previewing. Just don’t forget to add the UIBezierPath+.swift file to your project if you’re going to use my code. You may find it in the sample code at the bottom of this tutorial.


Eraser

We also needed to implement an eraser tool. However, we were unable to use the annotation(at:CGPoint) method provided by PDFPage because it’s based on annotation’s bounds and returned annotation even when the user tapped on white space inside bounds rect.

In our case (where the user may write text with an Apple Pencil) it didn’t work as the user would expect. We needed to find out whether the path was tapped, not the path’s bounds. First, we implemented PDFAnnotation subclass with computable hitPath property (more details on that later) which is the final path of annotation. For performance reasons, it’s only calculated when the path is completed.

We found that this approach doesn’t work after closing the PDFDocument and opening it again, because all the previously created annotations of class PDFAnnotationWithPath became regular PDFAnnotation. So, we had to sacrifice a bit of performance and create an extension instead of subclass:

extension PDFAnnotation {
  func contains(point: CGPoint) -> Bool {
    var hitPath: CGPath?
    if let path = paths?.first {
    hitPath = path.cgPath.copy(strokingWithWidth: 10.0, lineCap: .round, lineJoin: .round, miterLimit: 0)
    }
    return hitPath?.contains(point) ?? false
  }
}
Enter fullscreen mode Exit fullscreen mode

It’s important to note that we’re using paths property of PDFAnnotation here — make sure to put your final path there. We’re also using ten points as the default stroking width for all lines to make it easier for the user to hit the path with the eraser.
We also implemented PDFPage extension to call the contains method:


extension PDFPage {
  func annotationWithHitTest(at: CGPoint) -> PDFAnnotation? {
    for annotation in annotations {
      if annotation.contains(point: at) {
        return annotation
      }
    }
    return nil
  }
}
Enter fullscreen mode Exit fullscreen mode

Finally, we need to update PDFDrawer methods to support the eraser tool:

func gestureRecognizerMoved(_ location: CGPoint) {
  guard let page = currentPage else { return }
  let convertedPoint = pdfView.convert(location, to: page)

  // Erasing
  if drawingTool == .eraser {
    removeAnnotationAtPoint(point: convertedPoint, page: page)
    return
  }


  // Drawing
  ...
}
Enter fullscreen mode Exit fullscreen mode

DrawingTool is an enum, containing all the drawing tools we have. Users may select different tools with different line width and color in the UI. We’re storing it as a property of PDFDrawer.
Apply the same changes to gestureRecognizerEnded method:

func gestureRecognizerEnded(_ location: CGPoint) {
  guard let page = currentPage else { return }
  let convertedPoint = pdfView.convert(location, to: page)
  // Erasing
  if drawingTool == .eraser {
    removeAnnotationAtPoint(point: convertedPoint, page: page)
    return
  }
  // Drawing
  ...
}
Enter fullscreen mode Exit fullscreen mode

Here’s removeAnnotationAtPoint implementation:

 private func removeAnnotationAtPoint(point: CGPoint, page: PDFPage) {
  if let selectedAnnotation = page.annotationWithHitTest(at: point) {
    selectedAnnotation.page?.removeAnnotation(selectedAnnotation)
  }
}
Enter fullscreen mode Exit fullscreen mode

Saving the Document

Saving your PDFDocument after adding all the annotations you need is also very easy:

let path = Bundle.main.url(forResource: "YOUR_DOCUMENT_NAME", withExtension: "pdf")
pdfView.document?.write(to: path!)
Enter fullscreen mode Exit fullscreen mode

Here’s the final view of our app:

Image description


Sample Code

For a demo project, please check the repository.

I didn’t create a library or framework intentionally. Mostly because all the apps/projects to work with PDF are so different and require a lot of customizations. So, guys, feel free to use my sample code as a base for your own project.


Continue Reading

To learn more about PDFKit, including working with PencilKit, adding text annotations, and alternative solutions, continue to the second part.

Top comments (0)