markb
markb

Reputation: 1285

Generating PDF from array of SwiftUI views

I'm building an identity app which is stored all locally within the app on device.

Part of the app is the creation of IdentityCard for each user - with the standard things, name, image, position.

The size of the card is a standard credit card size: let cardSize: CGSize = .init(width: 153.8, height: 242.1) in the vertical position.

I want to create a "generate PDF" button so that each user's ID card will be printed into a PDF. I have the ability to select the paper size (A4, Letter, or custom - where they can select their own width, height, and unit).

At the moment I have this to generate the PDF, but I am running into a few issues:

  1. It doesn't output anything in a .sheet
  2. It is slow to run
  3. It crashes when testing on larger sets of users
import SwiftUI
import PDFKit
class PDFDataManager {
  static let shared = PDFDataManager()
  private init() {}
  struct Item<Content : View> {
    let views: [Content]
    let width: CGFloat
    let height: CGFloat
  }
  func generate<Content : View>(
    from item: Item<Content>,
    paper: PageSize = .a4,
    margin: Double = 36,
    bleed: Double = 10
  ) -> PDFDocument {
    let usablePageSize: CGSize = .init(
      width: paper.size.width - margin - (2 * bleed),
      height: paper.size.height - margin - (2 * bleed)
    )
    let itemSize: CGSize = .init(
      width: item.width,
      height: item.height
    )
    let maxRows = Int(floor(usablePageSize.height / itemSize.height))
    let maxCols = Int(floor(usablePageSize.width / itemSize.width))
    let pdfDocument = PDFDocument()
    let pageSize = CGRect(
      x: 0, y: 0,
      width: paper.size.width,
      height: paper.size.height
    )
    var currentItem = 0
    var currentRow = 0
    var currentCol = 0
    DispatchQueue.global(qos: .userInitiated).async {
      while currentItem < item.views.count {
        let pdfPage = PDFPage()
        pdfPage.setBounds(pageSize, for: .trimBox)
        let pdfView = PDFView(frame: pageSize)
        pdfView.autoScales = true
        pdfView.displayDirection = .vertical
        pdfView.displayMode = .singlePageContinuous
        pdfView.document = pdfDocument
        pdfView.pageBreakMargins = .init(top: 0, left: 0, bottom: 0, right: 0)
        while currentItem < item.views.count && currentRow <= maxRows {
          autoreleasepool {
            DispatchQueue.main.async {
              if currentItem < item.views.count {
                let itemView = UIHostingController(rootView: item.views[currentItem])
                let x = CGFloat(currentCol) * (itemSize.width + (bleed * 2))
                let y = CGFloat(currentRow) * (itemSize.height + (bleed * 2))
                let itemRect = CGRect(
                  x: x, y: y,
                  width: itemSize.width,
                  height: itemSize.height
                )
                itemView.view.frame = itemRect
                pdfView.addSubview(itemView.view)
                currentCol += 1
                if currentCol >= maxCols {
                  currentCol = 0
                  currentRow += 1
                }
                currentItem += 1
              }
            }
          }
        }
        DispatchQueue.main.async {
          pdfDocument.insert(pdfPage, at: pdfDocument.pageCount)
        }
      }
    }
    return pdfDocument
  }
}

What I've aimed to do / What my aim is to do:

  1. Pass in all the items through a temp struct
  2. Have a page margin, and an item bleed spacer
  3. Calculate the max items in a row
  4. Calculate the max items in a column
  5. Loop over all the items and place them onto the page

Once I would have that working I would be able to share the PDF, print the PDF, or export the PDF. However, at the moment I cant do any of that and I'm kind of lost of where to go from here.

I am targeting iOS/iPadOS 15 and above.

Upvotes: 2

Views: 479

Answers (1)

markb
markb

Reputation: 1285

This question was a bit harder to answer than what I thought, but I did manage to get it solved.

I do want to flag that there would probably be smarter, more efficient, and better implementation - but this worked for me.

I started with this framework which helped me build the PDF. It is fairly fleshed out and simple to use.

Using that, I had this code as my even spreading across the pages:

func generatePDF(_ items: [Item]) {

  let pageSize: PageSize = .a4
  let pageMargin: UIEdgeInsets = .equal(20)
  let imageSize = CGSize(width: 160, height: 250)
  let pdf = PDFMKit(pageSize: pageSize, pageMargin: pageMargin)
  let usablePageSize = CGSize(
    width: pageSize.size.width - pageMargin.left - pageMargin.right,
    height: pageSize.size.height - pageMargin.top - pageMargin.bottom
  )

  // -- get the max rows and columns per page
  let maxColumnsPerPage = Int(usablePageSize.width / imageSize.width)
  let maxRowsPerPage = Int(usablePageSize.height / imageSize.height)
  let maxItemsPerPage = maxRowsPerPage * maxColumnsPerPage

  // -- get the total item count
  let totalItems = users.count

  // -- create a chunked array
  let pageChunks = users.chunked(into: maxItemsPerPage)

  // -- run the operation in the background
  DispatchQueue.global(qos: .background).async {

    // -- loop over the chunks
    // -- this should be all the items per page
    for (pageIndex, pageChunk) in pageChunks.enumerated() {

      // -- rechunk the page items into the rows
      let pageItemChunks = pageChunk.chunked(into: maxColumnsPerPage)

      // -- loop over the rows
      for pageItemChunk in pageItemChunks {

        // -- get an array of the images
        let images = pageItemChunk.map { user in
          UIImage(
            data: user.cardImageData ?? .init(),
            scale: .screenScale
          ) ?? .init()
        }

        // -- start the horizontal alignment
        pdf.beginHorizontalArrangement()

        // -- add in the images
        for image in images {
          pdf.addImage(image)
        }

        // -- end the horizontal alignment
        pdf.endHorizontalArrangement()

        // -- update the progress bar
        if let lastItem = pageItemChunk.last,
           let lastIndex = users.firstIndex(of: lastItem) {
           DispatchQueue.main.async {
             progress(Double(lastIndex + 1) / Double(totalItems))
           }
         }
       }

       // -- create a new page at the end
       if pageIndex < pageChunks.count - 1 {
         pdf.beginNewPage()
       }
     }

     // -- update the main thread data
     DispatchQueue.main.async {
       let title = UUID().uuidString
       let pdfData = pdf.generate(title,
                                  author: "Author Name",
                                  subject: Bundle.main.displayName)
       do {
         let savedURL = try self.savePDF(pdfData, name: title)
         completion(.success(savedURL))
       } catch {
         completion(.failure(.unableToGenerate))
       }
     }
   }
 }
}

My implementation is similar but I do have some customisations to the source code to make it fully work for me. But the above should help anyone who needs it!

Upvotes: 0

Related Questions