Skip to content

Instantly share code, notes, and snippets.

@clayellis
Last active July 12, 2024 06:39
Show Gist options
  • Save clayellis/7a4c708dcee3a8cae53e3db7c866c966 to your computer and use it in GitHub Desktop.
Save clayellis/7a4c708dcee3a8cae53e3db7c866c966 to your computer and use it in GitHub Desktop.
import UIKit
import PDFKit
class PDFViewController: UIViewController {
let pdfDocument: PDFDocument
fileprivate let pdfView = PDFView()
enum PDFError: Error {
case failedToLoadPDFDocument
}
convenience init(url: URL) throws {
guard let document = PDFDocument(url: url) else {
throw PDFError.failedToLoadPDFDocument
}
self.init(document: document)
}
convenience init(data: Data) throws {
guard let document = PDFDocument(data: data) else {
throw PDFError.failedToLoadPDFDocument
}
self.init(document: document)
}
init(document: PDFDocument) {
self.pdfDocument = document
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
unsubscribeFromNotifications()
}
override func loadView() {
let view = UIView()
view.backgroundColor = .blue
view.addSubview(pdfView)
pdfView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
pdfView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
pdfView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
pdfView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
pdfView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])
pdfView.backgroundColor = .red
pdfView.pageShadowsEnabled = true
pdfView.autoScales = true
pdfView.displaysPageBreaks = false
pdfView.displayMode = .singlePage
pdfView.displayDirection = .horizontal
pdfView.usePageViewController(true, withViewOptions: [
UIPageViewController.OptionsKey.interPageSpacing: 0
])
pdfView.overrideCenterAlign()
queueingScrollView?.showsHorizontalScrollIndicator = false
self.view = view
}
override func viewDidLoad() {
super.viewDidLoad()
subscribeToNotifications()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadPDF()
}
func loadPDF() {
pdfView.document = pdfDocument
pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit
}
func currentPageIndex() -> Int? {
pdfView.currentPage.map(pdfDocument.index)
}
private func subscribeToNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(pageChanged(notification:)), name: .PDFViewPageChanged, object: pdfView)
}
private func unsubscribeFromNotifications() {
NotificationCenter.default.removeObserver(self, name: .PDFViewPageChanged, object: pdfView)
}
@objc private func pageChanged(notification: Notification) {
print("Page changed: \(currentPageIndex().map(String.init) ?? "nil")")
}
fileprivate var queueingScrollView: UIScrollView? {
return pdfView.firstSubview(withClassName: "_UIQueuingScrollView") as? UIScrollView
}
@objc fileprivate func handlePDFScrollViewPinch(gesture: UIPinchGestureRecognizer) {
guard gesture.state == .ended else {
return
}
guard let pdfScrollView = gesture.view as? UIScrollView else {
return
}
let snapScaleFactor = pdfView.minScaleFactor
let threshold: CGFloat = 0.08
let snapRange = (snapScaleFactor - threshold ... snapScaleFactor + threshold)
if snapRange.contains(pdfScrollView.zoomScale) {
pdfScrollView.setZoomScale(snapScaleFactor, animated: true)
}
}
}
extension PDFView {
func overrideCenterAlign() {
guard let _centerAlign = class_getInstanceMethod(NSClassFromString("PDFPageViewController"), Selector(extendedGraphemeClusterLiteral: "_centerAlign")) else {
return
}
guard let centerAlign = class_getInstanceMethod(UIViewController.self, #selector(UIViewController.centerAlign)) else {
return
}
method_exchangeImplementations(_centerAlign, centerAlign)
}
}
fileprivate extension UIViewController {
@objc dynamic func centerAlign() {
// We are in the PDFPageViewController's frame of reference now (which is just a UIViewController subclass)
// Since this method implementation has been swapped, we're actually calling the original implementation of _centerAlign and *not* looping here.
self.centerAlign()
guard let pdfScrollView = view.firstSubview(ofType: UIScrollView.self),
let pdfTextInputView = pdfScrollView.firstSubview(withClassName: "PDFTextInputView"),
let pdfController = firstResponder(ofType: PDFViewController.self)
else {
return
}
pdfScrollView.alwaysBounceVertical = true
pdfScrollView.pinchGestureRecognizer?.addTarget(pdfController, action: #selector(PDFViewController.handlePDFScrollViewPinch(gesture:)))
/// The current scale applied to the pdf
let scale = pdfTextInputView.transform.a
/// The pdf's frame without any tranforms applied
let originalFrame = pdfTextInputView.frame.applying(pdfTextInputView.transform.inverted())
/// The min scale factor where the width of the pdf is the width of the view
let widthRatio: CGFloat = view.frame.width / originalFrame.width
/// The scale at which the height of the pdf is the height of the view
let heightRatio = view.frame.height / originalFrame.height
// If the pdf is taller than the view
if originalFrame.height > view.frame.height {
// If the user is dragging between pages
// TODO: If the user has zoomed in and scrolled, then begins to page forward/backward but cancels,
// the pdf will jump and be resized. It isn't occurring due to this logic, I believe that it's from
// the original implementation when the view settles.
if let queuingScrollView = pdfController.queueingScrollView, queuingScrollView.isDragging && !queuingScrollView.isDecelerating {
pdfScrollView.setZoomScale(widthRatio, animated: false)
pdfScrollView.setContentOffset(.zero, animated: false)
}
}
// Otherwise, the pdf is the as tall or shorter than the view
else {
/// The distance between the top of the pdf and the top of the view when the pdf isn't scaled.
let yOffset = (view.frame.height - (originalFrame.height * widthRatio)) / 2
/// The distance the pdf should be offset in relation to the current scale
let scaledTY: CGFloat = yOffset * (1 / scale)
// Scale the view in the same way that the actual implementation would and apply the vertical translation
pdfTextInputView.transform = CGAffineTransform
.identity
.scaledBy(x: scale, y: scale)
.translatedBy(x: 0, y: -scaledTY)
// If the height of the pdf taller than or as tall as the view we need to
// adjust the transform and shift it down by the original y offset.
if pdfTextInputView.frame.height >= view.frame.height {
pdfTextInputView.transform.ty += yOffset
}
// Otherwise, the pdf is shorter than the view and we need to scaled the
// transform adjustment proprotional to the progress of the scale
else {
/// The progress of the scale starting at the minScaleFactor moving
/// towards the scale factor at which the pdf becomes as tall or taller
/// than the view.
let progress = (scale - widthRatio) / (heightRatio - widthRatio)
let scaledAdjustment = yOffset * progress
pdfTextInputView.transform.ty += scaledAdjustment
}
}
}
}
// MARK: Helpers
extension UIView {
var allSubviews: Set<UIView> {
var all = subviews
for subview in all {
all.append(contentsOf: subview.allSubviews)
}
return Set(all)
}
func firstSubview<T>(ofType classType: T.Type) -> T? {
return allSubviews.first { $0 is T } as? T
}
func firstSubview(withClassName className: String) -> UIView? {
return allSubviews.first { type(of: $0).description() == className }
}
}
extension UIResponder {
func firstResponder<T>(ofType classType: T.Type) -> T? {
var outer = self.next
while let next = outer {
if let found = next as? T {
return found
}
outer = next.next
}
return nil
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment