Last active
July 12, 2024 06:39
-
-
Save clayellis/7a4c708dcee3a8cae53e3db7c866c966 to your computer and use it in GitHub Desktop.
Answer to my Stack Overflow question: https://stackoverflow.com/questions/61874453/ios-pdfkit-adjust-pdf-frame-in-pdfview
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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