I am trying to set the top anchor point of a pdf that is inside of a view with the .ignoresSafeArea() modifier. I would also like it to work on the edges when the phone is in landscape although for simplicity I will only explain what I want for the top. I want it to function like the iOS Files app pdf viewer where when tapped it hides the navigation bars but the top of the pdf stays at the same place, but when you zoom in on the pdf it can fill the whole screen. When you zoom back out the top should return to the same place as before. Here is a simple view to show how it is being used:
@MainActor
struct ContentView: View {
@State var showBars: Bool = true
@State var pdfUrl: URL?
var body: some View {
NavigationStack {
GeometryReader { geo in
ScrollView {
TabView {
if let url = pdfUrl {
PDFViewer(pdfUrl: url)
.onTapGesture {
withAnimation {
showBars.toggle()
}
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(width: geo.size.width, height: geo.size.height)
}
.scrollDisabled(true)
}
.ignoresSafeArea(edges: !showBars ? .all : [])
}
.task {
pdfUrl = renderPDF()
}
}
func renderPDF() -> URL {
let renderer = ImageRenderer(content: VStack {})
let url = URL.documentsDirectory.appending(path: "samplepdf.pdf")
renderer.render { size, context in
guard let pdf = CGContext(url as CFURL, mediaBox: nil, nil) else {
return
}
pdf.beginPDFPage(nil)
context(pdf)
pdf.endPDFPage()
pdf.beginPDFPage(nil)
context(pdf)
pdf.endPDFPage()
pdf.closePDF()
}
return url
}
}
And here is what my pdfView looks like so far:
struct PDFViewer: View {
var pdfUrl: URL
var body: some View {
PDFSheetView(document: .init(url: pdfUrl))
}
}
class PDFViewController: UIViewController {
let document: PDFDocument?
var pdfView: PDFView!
init(document: PDFDocument?) {
self.document = document
super.init(nibName: nil, bundle: nil)
}
override func loadView() {
let view = PDFView()
self.view = view
self.pdfView = view
view.document = document
view.displayDirection = .vertical
view.autoScales = true
view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
}
required init?(coder: NSCoder) {
document = nil
super.init(coder: coder)
return nil
}
override func viewDidLayoutSubviews() {
let bounds = view.bounds
if let document {
if let page = document.page(at: 0) {
let pageBounds = page.bounds(for: .mediaBox)
if bounds.width > 0 && pageBounds.width > 0 {
let scaleFactor = bounds.width / pageBounds.width
let subtractionFactor = scaleFactor * 0.0125
pdfView.minScaleFactor = scaleFactor - subtractionFactor
}
}
}
}
}
struct PDFSheetView: UIViewControllerRepresentable {
typealias UIViewControllerType = PDFViewController
let document: PDFDocument?
func makeUIViewController(context: Context) -> PDFViewController {
let controller = PDFViewController(document: document)
return controller
}
func updateUIViewController(_ uiViewController: PDFViewController, context: Context) {
}
}
Is this possible to do? Like I said before, I want it to function just like the iOS Files app pdf viewer.
You can achieve this by listening to the PDFView
notification for scale changes. Then you can use a Binding<Bool>
to propagate the change to your ContentView
.
@MainActor
struct ContentView: View {
@State var showBars: Bool = true
@State var pdfUrl: URL?
var body: some View {
NavigationStack {
GeometryReader { geo in
ScrollView {
TabView {
if let url = pdfUrl {
// pass the binding on to the subview
PDFViewer(pdfUrl: url, showBars: $showBars)
.onTapGesture {
withAnimation {
showBars.toggle()
}
}
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.frame(width: geo.size.width, height: geo.size.height)
}
.scrollDisabled(true)
}
.ignoresSafeArea(edges: !showBars ? .all : [])
// hide status bar
.statusBar(hidden: !showBars)
// smoothly animate changes
.animation(.default, value: showBars)
}
.task {
pdfUrl = renderPDF()
}
}
....
This view seems kind of pointless (at least in the context of the question) but nevertheless pass the Binding on again.
struct PDFViewer: View {
var pdfUrl: URL
@Binding var showBars: Bool
var body: some View {
PDFSheetView(document: .init(url: pdfUrl), showBars: $showBars)
}
}
struct PDFSheetView: UIViewControllerRepresentable {
typealias UIViewControllerType = PDFViewController
let document: PDFDocument?
@Binding var showBars: Bool
func makeUIViewController(context: Context) -> PDFViewController {
// pass the binding on to the ViewController
let controller = PDFViewController(document: document, showBars: $showBars)
return controller
}
func updateUIViewController(_ uiViewController: PDFViewController, context: Context) {
}
}
Inside the ViewController add a Binding and subscribe to the NotificationCenter...
class PDFViewController: UIViewController {
let document: PDFDocument?
var pdfView: PDFView!
@Binding var showBars: Bool
init(document: PDFDocument?, showBars: Binding<Bool>) {
self.document = document
self._showBars = showBars // assign the Binding
super.init(nibName: nil, bundle: nil)
// subscribe to the NotificationCenter
NotificationCenter.default.addObserver(self, selector: #selector(didZoom), name: Notification.Name.PDFViewScaleChanged, object: nil)
}
// here we calculate the zoom of the PDFView.
// we need to divide the actual scale by the scale that fits
// the screen so we get the zoom from the user
@objc
private func didZoom(_ sender: Any){
self.showBars = (pdfView.scaleFactor / pdfView.scaleFactorForSizeToFit) <= 1.1
}