Showing PDFs is pretty common for an iOS app. It could be a privacy policy document, an invoice, a flyer the company distributes on paper and via its app, a magazine.
There are three possible ways of displaying a PDF.
The simplest is to use a Webview. Just like loading a local page, or a remote one, you can provide the URL of the PDF and the WKWebView will take care of it. The only downside is the scrolling is only vertical, which is fine for most apps but you may want to provide some customisations, for example the ability to scroll horizontally and to have two pages side-by-side in landscape.
I said we have 3 ways to show the PDF and we just saw one, so what are the remaining two?
In iOS 11 Apple introduced a new class PDFView, a subclass of UIView that comes with a configuration, so you can have side-by-side pages, vertical and horizontal scrolling and many more options.
The third alternative is drawing PDF pages and place them on a UIScrollView.
I started working on the custom solution back when we didn’t have WKWebViews nor PDF Kit, so my only alternatives were displaying PDFs in a UIWebView or implementing a custom solution.
Recently I wanted to port my Objective-C code to Swift and I decided to share the implementation, so I created this GitHub project https://github.com/gualtierofrigerio/GFPDFViewer
where I put two implementations: the scrollview with PDF pages and the one with PDFView. Using a Webview is so simple I didn’t feel the need to write the example, I’m sure there are tons of them available.
The custom solution
Let’s start with the UIScrollView containing views with single PDF pages.
As I mentioned before this was the solution I had to implement a few years ago, I cut some corners for the Swift porting as I don’t expect to use this in production now that iOS supports it in the framework.
I wrote about scrolling images in a UIScrollView in another post https://www.gfrigerio.com/building-an-image-gallery-app
so in this article I’ll write about PDF specific stuff as the image gallery was a better example of how to scroll views efficiently.
func loadDocument(atPath path:String) -> Bool {
guard let url = CFURLCreateWithFileSystemPath(nil, path as CFString, .cfurlposixPathStyle, false) else {
return false
}
document = CGPDFDocument(url)
return true
}
This is the weird part, we can’t use URL directly but we need to create a CFURL with the CFURLCreateWithFileSystemPath function. We then create the actual document, which stores the PDF file and has a few functions, the ones we use are numberOfPages to get the total number of pages in the document and page(at:) to get a CGPDFPage object representing a single page in the document.
To actually display the PDF page on screen we need to draw it with CoreGraphics and you can find the implementation on my GFPDFTiledView https://github.com/gualtierofrigerio/GFPDFViewer/blob/master/GFPDFViewer/Classes/GFPDFTiledView.swift
I used a CATiledLayer because I found it to perform better on the hardware I had back in the days. This kind of approach is great if you have large images and is used in Maps as well.I want to point out two functions: the one responsible for drawing a PDF on the layer and the utility function I use to compute the scale and offset of the page. I hope you’ll find them useful even in other contexts, as it is basic geometry in the case of the utility function.
override func draw(_ layer: CALayer, in ctx: CGContext) {
// Fill the background with white.
ctx.setFillColor(UIColor.white.cgColor)
ctx.fill(currentFrame)
guard let page = self.page else {return}
let box = page.getBoxRect(.mediaBox)
let (xScale, yScale, xTranslate, yTranslate) = getTranslationAndScale(forRect: currentFrame, box: box)
let xScaleA = xScale * scale
let yScaleA = yScale * scale
ctx.scaleBy(x: xScaleA, y: yScaleA)
ctx.saveGState()
ctx.translateBy(x: xTranslate, y: yTranslate + box.size.height)
// Flip the context so that the PDF page is rendered right side up.
ctx.scaleBy(x: 1.0, y: -1.0)
ctx.saveGState()
ctx.clip(to: box)
ctx.drawPDFPage(page)
ctx.restoreGState()
}
draw is called when the system needs to draw the view. You can force the drawing by calling setNeedsDisplay on the layer, but usually the system calls the function at the right time.
CGContext is the CoreGraphic context we can use to draw things on a layer. It is possible to set colors, apply transformations like scaling and translation, draw lines etc.
I start by filling the layer with white, so we have a background for our PDF. Then I get the scale and translation values from the utility function, and apply them via scaleBy and TranslateBy. One thing I found out is the PDF is displayed upside down, unless you apply a negative scale on the y axis. I can then draw the PDF page I got from the document provider and that’s the end of it.
func getTranslationAndScale(forRect rect:CGRect, box:CGRect) -> (CGFloat, CGFloat, CGFloat, CGFloat) {
let boxRatio = box.size.width / box.size.height;
var xScale:CGFloat = 1.0
var yScale:CGFloat = 1.0
var xTranslate:CGFloat = 0.0
var yTranslate:CGFloat = 0.0
xScale = rect.size.width > box.size.width ? 1 : rect.size.width / box.size.width;
var newSize = CGSize()
newSize.width = box.size.width * xScale
newSize.height = newSize.width / boxRatio
if newSize.height > rect.size.height {
newSize.height = rect.size.height
newSize.width = newSize.height * boxRatio
}
xScale = newSize.width / box.size.width
yScale = newSize.height / box.size.height
xTranslate = (rect.size.width - newSize.width) / 2
yTranslate = (rect.size.height - newSize.height) / 2
return (xScale, yScale, xTranslate, yTranslate)
The PDF page may not fit our view perfectly, so we need to scale it and center it on the view. CGPDFPage has a function to get the size of the page, getBoxRect, so we have our view’s frame and the page’s rect and we have to fit the page into the frame.
I don’t want to zoom the page, so the scale it either going to be 1 or less than 1. First I compute the width, I want to make sure it is less or equal than our view’s width. I then check the height, and if it is bigger than the view I set the new height to match the view and the width accordingly, always keeping the same aspect ratio. The xTranslate and yTranslate values put the PDF page in the middle of the view.
The pages are put into a UIScrollView, I create an helper class to compute the pages in single pare or side-by-side so I can load the correct page if I rotate the device and change the number of pages on screen. I always display the leftmost page if I switch between 2 pages to 1.
PDFKit
PDFKit was introduced in iOS 11 and is available on macOS as well, but I guess many developers are waiting for Marzipan to port their apps so are not really interested in AppKit. Sorry for the diversion 🙂
For now I’ll cover the only class needed to display a PDF, called PDFView. PDFKit comes with many more features, like PDFThumbnailView to show thumbnails of the document, there is the equivalent of CGPDFDocument called PDFDocument, and it is possible to handle text selection and add annotation. If I’ll get to work on some of those features in the future I’ll be happy to share my experience by adding code to the GitHub project and write about it on this article.
If you’re interested in PDFKit I recommend the WWDC session https://developer.apple.com/videos/play/wwdc2017/241/
The code responsible for dealing with PDFKit can be found in the class GFPDFKitViewController https://github.com/gualtierofrigerio/GFPDFViewer/blob/master/GFPDFViewer/Classes/GFPDFKitViewController.swift
Instead of loading the document and asking the document provider for a single page we can get the PDFDocument object and pass it to the PDFView.
func loadPDFDocument(document: PDFDocument) {
pdfView.document = document
}
That’s it. All we need to do is creating an instance of PDFView and setting its document property. The PDF will start to show right after we set the document and of course add the view in our views hierarchy. It is as easy as letting WKWebView do all the work, but as I said the main advantage of PDFView even if you only need to display a PDF is the ability to configure the scroll direction and whether to show one or two pages at the same time.
private func configurePDFView() {
if configuration.scrollVertically {
pdfView.displayDirection = .vertical
}
else {
pdfView.displayDirection = .horizontal
}
pdfView.contentMode = .scaleAspectFit
pdfView.maxScaleFactor = 4.0
pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit
pdfView.autoScales = true
}
We can chose between horizontal and vertical scrolling by setting the displayDirection property. We can then set the minimum and maximum scaling factor and let PDFView scale automatically to fit the size of the view. What if we want to show a single page in portrait and two pages side-by-side in landscape? We need to set the displayMode property
func setNumberOfPagesOnScreen(_ pages: Int) {
if pages > 1 && configuration.sideBySideLandscape {
pdfView.displayMode = .twoUpContinuous
}
else {
pdfView.displayMode = .singlePageContinuous
}
}
The enum has 4 values. You can show a single page, two of them and you can have the view display a series of pages as the user scrolls by setting singlePageContinuous and twoUpContinuous.
PDFView comes with an handful of functions to go to the next or previous page, to go to a particular page and even to go to a coordinate of a page if you want to link to an anchor. If you want to show a small preview of a page you can either use PDFThumbnailView, that will show all the pages and allow you to select a thumbnail and immediately show the corresponding page in a PDFView (all you need to do is set the pdfView property in your thumbnail view) or get a UIImage from a PDFPage object
As usual I’ll update the article as soon as I make changes to the GitHub project. I plan to implement search and use some of the possibilities offered by PDFKit like getting a quick preview, add annotations and even make PDF documents.