Generating PDFs Dynamically Using PDFKit in Swift

Build a cocktail menu PDF using a public API

·

13 min read

Generating PDF Reports dynamically can be extremely useful while developing Swift applications. Your users will be capable of creating on the go their own personalized reports that they will be able to export very easily.

Goals

  • Generate a PDF Document
  • Present your data in an organized fashion
  • Format your text (Font, Size, Underline…)
  • Move on to a new page when needed
  • Share your PDF

1.png

Overview

In this tutorial, we are going to generate a cocktail menu using the public thecocktaildbAPI.

After testing their search API on our browser with an empty search string “https://www.thecocktaildb.com/api/json/v1/1/search.php?s=” we can see a list of cocktails with their ingredients as well as some instructions and additional data.

This API also allows us to filter by drink.

For instance, using the URL “https://www.thecocktaildb.com/api/json/v1/1/search.php?s=margarita” would show us all the Margaritas.

We are going to develop an app that generates a PDF showing us a list of all cocktails filtered by a string that the user will type in.

Creating the App

First of all, let’s start by creating a new single view application by clicking on XCode, and selecting create a new XCode project.

2.png

After that, let’s select App.

3.png

I went ahead and called the App Margaritas PDFKit. Select Storyboard and Swift and click on Next and then Finish.

Fetching the Data

Fetching and parsing the data is a little out of scope of this tutorial so let’s go through it quickly.

Create a new Swift file, call it NetworkManager.swift

4.png

5.png

… and type in the following:

import Foundation

class NetworkManager {

    static var shared : NetworkManager = NetworkManager()

    private let margaritaUrl = "https://www.thecocktaildb.com/api/json/v1/1/search.php?s="

    func getCocktails(searchQuery: String, success: @escaping (_ menu: Menu) -> Void, failure: @escaping (_ error: String) -> Void) {

        var semaphore = DispatchSemaphore (value: 0)

        var request = URLRequest(url: URL(string: "\(margaritaUrl)\(searchQuery)")!,timeoutInterval: Double.infinity)
        request.httpMethod = "GET"

        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            guard let data = data else {
                failure(error?.localizedDescription ?? "GET error")
                semaphore.signal()
                return
            }
            let decoder = JSONDecoder()
            do {
                let response = try decoder.decode(Menu.self, from: data)
                success(response)
            } catch {
                failure(error.localizedDescription)
            }
            semaphore.signal()
        }

        task.resume()
        semaphore.wait()
    }

}

“Error: Cannot find Menu in scope”, let’s add it right away!

Create a new Swift file called Cocktail.swift by right clicking on the “Margaritas PDFKit” Group and click on New File:

6.png

7.png

Call the file Cocktail.swift and click on Create.

Now paste in the file the following:

class Menu: Codable {
    var drinks: [Cocktail]?

    init(drinks: [Cocktail]?) {
        self.drinks = drinks
    }
}

class Cocktail: Codable {
    var idDrink, strDrink: String?
    var strCategory, strAlcoholic: String?
    var strGlass, strInstructions: String?
    var strIngredient1, strIngredient2, strIngredient3, strIngredient4: String?
    var strMeasure1, strMeasure2, strMeasure3, strMeasure4: String?

    init(idDrink: String?, strDrink: String?, strCategory: String?, strAlcoholic: String?, strGlass: String?, strInstructions: String?, strIngredient1: String?, strIngredient2: String?, strIngredient3: String?, strIngredient4: String?, strMeasure1: String?, strMeasure2: String?, strMeasure3: String?, strMeasure4: String?) {
        self.idDrink = idDrink
        self.strDrink = strDrink
        self.strCategory = strCategory
        self.strAlcoholic = strAlcoholic
        self.strGlass = strGlass
        self.strInstructions = strInstructions
        self.strIngredient1 = strIngredient1
        self.strIngredient2 = strIngredient2
        self.strIngredient3 = strIngredient3
        self.strIngredient4 = strIngredient4
        self.strMeasure1 = strMeasure1
        self.strMeasure2 = strMeasure2
        self.strMeasure3 = strMeasure3
        self.strMeasure4 = strMeasure4
    }
}

This class will parse the response of the Cocktail API into these classes.

By using Codable, the JSON response will be mapped automatically into the objects we have here.

The User Interface

First of all, let’s jump to the main.storyboard and add in a few objects from the library with the following constraints:

A UITextField

8.png

9.png

A UILabel

10.png

11.png

And a UIButton

12.png

Now create an action by: ctrl + click from the button to the ViewController.swift file, and call the method generatePDF.

13.png

Also, do the same thing and create an outlet from the TextField to the Siwft file and call the outlet queryField.

Representing the Text

Let us start by defining functions capable of assigning attributes to text and presenting this text in our context

First of all, make sure to add on the top of the ViewController.swift file:

import PDFKit

and then add at the bottom of the file:

extension UIGraphicsPDFRendererContext {

    // 1
    func addCenteredText(fontSize: CGFloat,
                         weight: UIFont.Weight,
                         text: String,
                         cursor: CGFloat,
                         pdfSize: CGSize) -> CGFloat {

        let textFont = UIFont.systemFont(ofSize: fontSize, weight: weight)
        let pdfText = NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: textFont])

        let rect = CGRect(x: pdfSize.width/2 - pdfText.size().width/2, y: cursor, width: pdfText.size().width, height: pdfText.size().height)
        pdfText.draw(in: rect)

        return self.checkContext(cursor: rect.origin.y + rect.size.height, pdfSize: pdfSize)
    }

    // 2
    func addSingleLineText(fontSize: CGFloat,
                           weight: UIFont.Weight,
                           text: String,
                           indent: CGFloat,
                           cursor: CGFloat,
                           pdfSize: CGSize,
                           annotation: PDFAnnotationSubtype?,
                           annotationColor: UIColor?) -> CGFloat {

        let textFont = UIFont.systemFont(ofSize: fontSize, weight: weight)
        let pdfText = NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: textFont])

        let rect = CGRect(x: indent, y: cursor, width: pdfSize.width - 2*indent, height: pdfText.size().height)
        pdfText.draw(in: rect)

        if let annotation = annotation {
            let annotation = PDFAnnotation(
                bounds: CGRect.init(x: indent, y: rect.origin.y + rect.size.height, width: pdfText.size().width, height: 10),
                forType: annotation,
                withProperties: nil)
            annotation.color = annotationColor ?? .black
            annotation.draw(with: PDFDisplayBox.artBox, in: self.cgContext)
        }

        return self.checkContext(cursor: rect.origin.y + rect.size.height, pdfSize: pdfSize)
    }

    // 3
    func addMultiLineText(fontSize: CGFloat,
                          weight: UIFont.Weight,
                          text: String,
                          indent: CGFloat,
                          cursor: CGFloat,
                          pdfSize: CGSize) -> CGFloat {

        let textFont = UIFont.systemFont(ofSize: fontSize, weight: weight)

        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .natural
        paragraphStyle.lineBreakMode = .byWordWrapping

        let pdfText = NSAttributedString(string: text, attributes: [NSAttributedString.Key.paragraphStyle: paragraphStyle, NSAttributedString.Key.font: textFont])
        let pdfTextHeight = pdfText.height(withConstrainedWidth: pdfSize.width - 2*indent)

        let rect = CGRect(x: indent, y: cursor, width: pdfSize.width - 2*indent, height: pdfTextHeight)
        pdfText.draw(in: rect)

        return self.checkContext(cursor: rect.origin.y + rect.size.height, pdfSize: pdfSize)
    }

    // 4
    func checkContext(cursor: CGFloat, pdfSize: CGSize) -> CGFloat {
        if cursor > pdfSize.height - 100 {
            self.beginPage()
            return 40
        }
        return cursor
    }
}

extension NSAttributedString {

    // 5
    func height(withConstrainedWidth width: CGFloat) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, context: nil)

        return ceil(boundingBox.height)
    }
}

These methods are added as an extension to UIGraphicsPDFRendererContext, which is the context that is going to render our PDF.

All we have to do is dictate to the context everything we need our PDF to have (PDF metadata, PDF dimensions, page breaks, and of course the text we are going to present on the document).

In the implementation of functions 1, 2 and 3, attributes are assigned to our text in an Attributed String variable. We then use the indent and Page Size parameters to place our string in a CGRect described by the function definition (centered, single line left aligned, and multi-line left aligned). After doing so, we use the function 4 (checkContext()) that will check if our text is exceeding the bounds of the page, and if so, start a new page dynamically and reset the cursor.

Finally, the function 5 calculates the height of a multi-line sentence, so that we know where the text ends and where to start the next paragraph.

Configuring the Context

As a next step, we are going to implement a function that takes a cocktail, extracts all the relevant information, and using the previous extensions, adds it to the context.

Implement the functions generateDrinkText() and addDrinksIngredients():

func generateDrinkText(drink: Cocktail, context: UIGraphicsPDFRendererContext, cursorY: CGFloat, pdfSize: CGSize) -> CGFloat {
    var cursor = cursorY
    let leftMargin: CGFloat = 74


    if let drinkName = drink.strDrink {
        cursor = context.addSingleLineText(fontSize: 14, weight: .bold, text:  drinkName, indent: leftMargin, cursor: cursor, pdfSize: pdfSize, annotation: .underline, annotationColor: .black)
        cursor+=6

        cursor = context.addSingleLineText(fontSize: 12, weight: .thin, text: "\(drink.strCategory ?? "Cocktail") | \(drink.strAlcoholic ?? "Might Contain Alcohol")", indent: leftMargin, cursor: cursor, pdfSize: pdfSize, annotation: nil, annotationColor: nil)
        cursor+=2

        cursor = addDrinkIngredients(drink: drink, context: context, cursorY: cursor, pdfSize: pdfSize)

        if let instructions = drink.strInstructions {
            cursor = context.addMultiLineText(fontSize: 9, weight: .regular, text: instructions, indent: leftMargin, cursor: cursor, pdfSize: pdfSize)
            cursor+=8
        }

        cursor+=8
    }

    return cursor
}

func addDrinkIngredients(drink: Cocktail, context: UIGraphicsPDFRendererContext, cursorY: CGFloat, pdfSize: CGSize) -> CGFloat {
    let ingredients = [drink.strIngredient1, drink.strIngredient2, drink.strIngredient3, drink.strIngredient4]
    let measures = [drink.strMeasure1, drink.strMeasure2, drink.strMeasure3, drink.strMeasure4]
    var cursor = cursorY
    let leftMargin: CGFloat = 74

    for i in 0..<ingredients.count {
        if let ingredient = ingredients[i], let measure = measures[i] {
            cursor = context.addSingleLineText(fontSize: 12, weight: .thin, text: " • \(ingredient) (\(measure))", indent: leftMargin, cursor: cursor, pdfSize: pdfSize, annotation: nil, annotationColor: nil)
            cursor+=2
        }
    }

    cursor+=8

    return cursor

}

In the function generateDrinkText(), the context is passed, as well as the drink. We are also passing the cursor to know where to start writing on the y-axis. This cursor value is also returned at the end of the function to be used on the next drink.

The other function addDrinkIngredients() facilitates checking for ingredients and presents them, given that not all cocktails have all ingredients.

Generating the PDF

Now we can finalize the generation process by creating a function that takes a Cocktail Array and returns Data that will be converted into a PDF Document later on.

Go to ViewController.swift and add the following inside a new function called generatePDFData()

func generatePdfData(drinks: [Cocktail]) -> Data {
    let pdfMetaData = [
        kCGPDFContextCreator: "thecocktaildb", // Creator of the PDF Document
        kCGPDFContextAuthor: "Marc Daou", // The Author
        kCGPDFContextTitle: "Cocktails Menu" // PDF Title
    ]

    let format = UIGraphicsPDFRendererFormat()
    format.documentInfo = pdfMetaData as [String: Any]

    let pageRect = CGRect(x: 10, y: 10, width: 595.2, height: 841.8) // Page size is set to A4
    let graphicsRenderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format) // Renderer that allows us to configure context

    let data = graphicsRenderer.pdfData { (context) in
        context.beginPage() // start a new page for the PDF

        let initialCursor: CGFloat = 32 // cursor is used to track the max y coordinate of our PDF to know where to append new text

        // Adding a title
        var cursor = context.addCenteredText(fontSize: 32, weight: .bold, text: "Cocktail Menu 🍹", cursor: initialCursor, pdfSize: pageRect.size)

        cursor+=42 // Add white space after the Title

        for drink in drinks {
            // wirte in our context the info of each drink
            cursor = generateDrinkText(drink: drink, context: context, cursorY: cursor, pdfSize: pageRect.size)
        }
    }
    return data
}

Now that we have a function that creates the PDF we need by configuring the context, all we have left to do is get the cocktails from our API and pass them to this function.

Our IBAction should now look like this:

@IBAction func generatePDF(_ sender: Any) {
    NetworkManager.shared.getCocktails(searchQuery: queryField.text ?? "") { menu in
        DispatchQueue.main.async {
            let pdfData = self.generatePdfData(drinks: menu.drinks ?? [])
            let pdfView = PDFView()

            // do something with this data
        }
    } failure: { error in
        print("Oops", error)
    }
}

Showing the PDF document from PDF Data

Go to the main.storyboard and add a new ViewController file.

14.png

15.png

16.png

Assign the new View Controller file to our new View Controller Scene like so.

17.png

Now add a segue from the initial ViewController to the new scene by using ctrl + click.

18.png

Select Present Modally, and then click on the segue and set the identifier like so:

19.png

Add a Toolbar and a UIVIew with the following coordinates:

20.png

21.png

22.png

23.png

24.png

Don’t forget to set the UIView class to “PDFView”!

25.png

After doing all of that paste the following into your new PdfPreviewVC:

import UIKit
import PDFKit

class PdfPreviewVC: UIViewController {

    public var documentData: Data?
    @IBOutlet weak var pdfView: PDFView!
    @IBOutlet weak var shareButton: UIBarButtonItem!

    override func viewDidLoad() {
        super.viewDidLoad()

        if let data = documentData {
            pdfView.translatesAutoresizingMaskIntoConstraints = false
            pdfView.autoScales = true
            pdfView.pageBreakMargins = UIEdgeInsets.init(top: 20, left: 8, bottom: 32, right: 8)
            pdfView.document = PDFDocument(data: data)

        }
    }

    @IBAction func share(_ sender: Any) {
        if let dt = documentData {
            let vc = UIActivityViewController(
              activityItems: [dt],
              applicationActivities: []
            )
            if UIDevice.current.userInterfaceIdiom == .pad {
                vc.popoverPresentationController?.barButtonItem = shareButton
            }
            self.present(vc, animated: true, completion: nil)
        }
    }
}

Link the shareButton to the toolbar button, the pdfView to the main UIView we added and of course the share function to the toolbar button.

Note: In case the link of the UIView isn’t working, make sure the class is set to PDFView from the storyboard assistant editor sidebar.

Finally, go back to the ViewController.swift and update the file so that it looks like so:

import UIKit
import PDFKit

class ViewController: UIViewController {

    @IBOutlet weak var queryField: UITextField!
    var pdfData: Data?

    override func viewDidLoad() {
        super.viewDidLoad()
    }

    @IBAction func generatePDF(_ sender: Any) {
        NetworkManager.shared.getCocktails(searchQuery: queryField.text ?? "") { menu in
            DispatchQueue.main.async {
                let pdfData = self.generatePdfData(drinks: menu.drinks ?? [])
                self.pdfData = pdfData
                self.performSegue(withIdentifier: "toPDFPreview", sender: self)
            }
        } failure: { error in
            print("Oops", error)
        }
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if let vc = segue.destination as? PdfPreviewVC {
            vc.documentData = pdfData
        }
    }

    func generatePdfData(drinks: [Cocktail]) -> Data {
        let pdfMetaData = [
            kCGPDFContextCreator: "thecocktaildb",
            kCGPDFContextAuthor: "Marc Daou",
            kCGPDFContextTitle: "Cocktails Menu"
        ]

        let format = UIGraphicsPDFRendererFormat()
        format.documentInfo = pdfMetaData as [String: Any]

        let pageRect = CGRect(x: 10, y: 10, width: 595.2, height: 841.8)
        let graphicsRenderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)

        let data = graphicsRenderer.pdfData { (context) in
            context.beginPage()

            let initialCursor: CGFloat = 32

            var cursor = context.addCenteredText(fontSize: 32, weight: .bold, text: "Cocktail Menu 🍹", cursor: initialCursor, pdfSize: pageRect.size)

            cursor+=42 // Add white space after the Title

            for drink in drinks {
                cursor = generateDrinkText(drink: drink, context: context, cursorY: cursor, pdfSize: pageRect.size)
            }
        }
        return data
    }

    func addDrinkIngredients(drink: Cocktail, context: UIGraphicsPDFRendererContext, cursorY: CGFloat, pdfSize: CGSize) -> CGFloat {
        let ingredients = [drink.strIngredient1, drink.strIngredient2, drink.strIngredient3, drink.strIngredient4]
        let measures = [drink.strMeasure1, drink.strMeasure2, drink.strMeasure3, drink.strMeasure4]
        var cursor = cursorY
        let leftMargin: CGFloat = 74

        for i in 0..<ingredients.count {
            if let ingredient = ingredients[i], let measure = measures[i] {
                cursor = context.addSingleLineText(fontSize: 12, weight: .thin, text: " • \(ingredient) (\(measure))", indent: leftMargin, cursor: cursor, pdfSize: pdfSize, annotation: nil, annotationColor: nil)
                cursor+=2
            }
        }

        cursor+=8

        return cursor

    }

    func generateDrinkText(drink: Cocktail, context: UIGraphicsPDFRendererContext, cursorY: CGFloat, pdfSize: CGSize) -> CGFloat {
        var cursor = cursorY
        let leftMargin: CGFloat = 74


        if let drinkName = drink.strDrink {
            cursor = context.addSingleLineText(fontSize: 14, weight: .bold, text:  drinkName, indent: leftMargin, cursor: cursor, pdfSize: pdfSize, annotation: .underline, annotationColor: .black)
            cursor+=6

            cursor = context.addSingleLineText(fontSize: 12, weight: .thin, text: "\(drink.strCategory ?? "Cocktail") | \(drink.strAlcoholic ?? "Might Contain Alcohol")", indent: leftMargin, cursor: cursor, pdfSize: pdfSize, annotation: nil, annotationColor: nil)
            cursor+=2

            cursor = addDrinkIngredients(drink: drink, context: context, cursorY: cursor, pdfSize: pdfSize)

            if let instructions = drink.strInstructions {
                cursor = context.addMultiLineText(fontSize: 9, weight: .regular, text: instructions, indent: leftMargin, cursor: cursor, pdfSize: pdfSize)
                cursor+=8
            }

            cursor+=8
        }

        return cursor
    }
}


extension UIGraphicsPDFRendererContext {

    func addCenteredText(fontSize: CGFloat,
                         weight: UIFont.Weight,
                         text: String,
                         cursor: CGFloat,
                         pdfSize: CGSize) -> CGFloat {

        let textFont = UIFont.systemFont(ofSize: fontSize, weight: weight)
        let pdfText = NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: textFont])

        let rect = CGRect(x: pdfSize.width/2 - pdfText.size().width/2, y: cursor, width: pdfText.size().width, height: pdfText.size().height)
        pdfText.draw(in: rect)

        return self.checkContext(cursor: rect.origin.y + rect.size.height, pdfSize: pdfSize)
    }

    func addSingleLineText(fontSize: CGFloat,
                           weight: UIFont.Weight,
                           text: String,
                           indent: CGFloat,
                           cursor: CGFloat,
                           pdfSize: CGSize,
                           annotation: PDFAnnotationSubtype?,
                           annotationColor: UIColor?) -> CGFloat {

        let textFont = UIFont.systemFont(ofSize: fontSize, weight: weight)
        let pdfText = NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: textFont])

        let rect = CGRect(x: indent, y: cursor, width: pdfSize.width - 2*indent, height: pdfText.size().height)
        pdfText.draw(in: rect)

        if let annotation = annotation {
            let annotation = PDFAnnotation(
                bounds: CGRect.init(x: indent, y: rect.origin.y + rect.size.height, width: pdfText.size().width, height: 10),
                forType: annotation,
                withProperties: nil)
            annotation.color = annotationColor ?? .black
            annotation.draw(with: PDFDisplayBox.artBox, in: self.cgContext)
        }

        return self.checkContext(cursor: rect.origin.y + rect.size.height, pdfSize: pdfSize)
    }

    func addMultiLineText(fontSize: CGFloat,
                          weight: UIFont.Weight,
                          text: String,
                          indent: CGFloat,
                          cursor: CGFloat,
                          pdfSize: CGSize) -> CGFloat {

        let textFont = UIFont.systemFont(ofSize: fontSize, weight: weight)

        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .natural
        paragraphStyle.lineBreakMode = .byWordWrapping

        let pdfText = NSAttributedString(string: text, attributes: [NSAttributedString.Key.paragraphStyle: paragraphStyle, NSAttributedString.Key.font: textFont])
        let pdfTextHeight = pdfText.height(withConstrainedWidth: pdfSize.width - 2*indent)

        let rect = CGRect(x: indent, y: cursor, width: pdfSize.width - 2*indent, height: pdfTextHeight)
        pdfText.draw(in: rect)

        return self.checkContext(cursor: rect.origin.y + rect.size.height, pdfSize: pdfSize)
    }

    func checkContext(cursor: CGFloat, pdfSize: CGSize) -> CGFloat {
        if cursor > pdfSize.height - 100 {
            self.beginPage()
            return 40
        }
        return cursor
    }
}

extension NSAttributedString {
    func height(withConstrainedWidth width: CGFloat) -> CGFloat {
        let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
        let boundingBox = boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, context: nil)

        return ceil(boundingBox.height)
    }
}

Testing Everything

Run the app, and fill in “margarita” or anything that comes to mind, and the PDF should be generated dynamically.

Try avoiding spaces or illegal URL characters.

26.png

27.png

We now have a drinks menu that can be generated from dynamic data, the pages also scale dynamically and multi-line text for the instructions is working Swiftly (pun intended).

Cheers 🥂.

Source Code: You can find the final code here: https://github.com/Marcdaou/PDFKit