iOS PDFKit Ink Annotations Tutorial
Tags: ios, swift, pdfkit
Published: 2023-03-19 13:32:49

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](https://dev.to/artem_poluektov/ios-pdfkit-tutorial-text-annotations-more-bc7) is about PencilKit, Text annotations and auto-saving.
- [The third](https://dev.to/artem_poluektov/ios-pdfkit-creating-pdf-document-in-swift-insertingdeleting-pages-4cdj) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rhq58mpsn7g5zxd0hwzq.png)

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)
```

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
}
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g5nf72p9b2k6g8jd7i33.png)

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)
```

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
}
```

_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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lxsg3mlx1oycmewyahbw.png)

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)
```

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
  }
}
```

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)
}
```

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

```
let pdfDrawingGestureRecognizer = DrawingGestureRecognizer()
pdfView.addGestureRecognizer(pdfDrawingGestureRecognizer)
```

---

## 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?
}
```

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)
}
```

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)
}
```

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
}
```

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
}
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/inujq1pfp3cjihg5979u.png)

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

```
private let pdfDrawer = PDFDrawer()

```

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

```
pdfDrawingGestureRecognizer.drawingDelegate = pdfDrawer
pdfDrawer.pdfView = pdfView
```

---

## 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)
  }
}
```

---

## 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()
  }
}
```

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

```
path.lineJoinStyle = .round
path.lineCapStyle = .round
```

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()
}
```

---

## 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)
}
```
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
}
```
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
  }
}
```
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
  }
}
```
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
  ...
}
```
`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
  ...
}
```

Here’s `removeAnnotationAtPoint` implementation:

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

---

## 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!)
```
Here’s the final view of our app:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eudr56hp48fey6qrxszt.png)

---

## Sample Code

For a demo project, please check the [repository](https://github.com/poluektov/pdfkit-ink-annotations/).

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](https://dev.to/artem_poluektov/ios-pdfkit-tutorial-text-annotations-more-bc7).