Photo by Wesual Click on Unsplash
Generating PDFs Dynamically Using PDFKit in Swift
Build a cocktail menu PDF using a public API
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
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.
After that, let’s select App.
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
… 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:
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
A UILabel
And a UIButton
Now create an action by: ctrl + click from the button to the ViewController.swift file, and call the method generatePDF.
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.
Assign the new View Controller file to our new View Controller Scene like so.
Now add a segue from the initial ViewController to the new scene by using ctrl + click.
Select Present Modally, and then click on the segue and set the identifier like so:
Add a Toolbar and a UIVIew with the following coordinates:
Don’t forget to set the UIView class to “PDFView”!
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.
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