Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file. Take a look
* This callback is called before executing any navigation action.
* Useful for hiding UI elements when the user navigates, or implementing analytics.
* Added swipe gesture support for navigating in PDF paginated spread mode.
* Added `fit` preference for PDF documents to control how pages are scaled within the viewport.
* Only effective in scroll mode. Paginated mode always uses page fit due to PDFKit limitations.

### Deprecated

Expand All @@ -26,6 +28,15 @@ All notable changes to this project will be documented in this file. Take a look

* Support for asynchronous callbacks with `onCreatePublication` (contributed by [@smoores-dev](https://github.com/readium/swift-toolkit/pull/673)).

#### Navigator

* The `Fit` enum has been redesigned to fit the PDF implementation.
* **Breaking change:** Update any code using the old `Fit` enum values.
* The PDF navigator's content inset behavior has changed:
* iPhone: Continues to apply window safe area insets (to account for notch/Dynamic Island).
* iPad/macOS: Now displays edge-to-edge with no automatic safe area insets.
* You can customize this behavior with `VisualNavigatorDelegate.navigatorContentInset(_:)`.

### Fixed

#### Navigator
Expand Down
227 changes: 226 additions & 1 deletion Sources/Navigator/PDF/PDFDocumentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,31 @@ public final class PDFDocumentView: PDFView {
}

private func updateContentInset() {
let insets = documentViewDelegate?.pdfDocumentViewContentInset(self) ?? window?.safeAreaInsets ?? .zero
let insets = contentInset
firstScrollView?.contentInset.top = insets.top
firstScrollView?.contentInset.bottom = insets.bottom
}

private var contentInset: UIEdgeInsets {
if let contentInset = documentViewDelegate?.pdfDocumentViewContentInset(self) {
return contentInset
}

// We apply the window's safe area insets (representing the system
// status bar, but ignoring app bars) on iPhones only because in most
// cases we prefer to display the content edge-to-edge.
// iPhones are a special case because they are the only devices with a
// physical notch (or Dynamic Island) which is included in the window's
// safe area insets. Therefore, we must always take it into account to
// avoid hiding the content.
if UIDevice.current.userInterfaceIdiom == .phone {
return window?.safeAreaInsets ?? .zero
} else {
// Edge-to-edge on macOS and iPadOS.
return .zero
}
}

override public func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
super.canPerformAction(action, withSender: sender) && editingActions.canPerformAction(action)
}
Expand All @@ -70,4 +90,209 @@ public final class PDFDocumentView: PDFView {
editingActions.buildMenu(with: builder)
super.buildMenu(with: builder)
}

var isPaginated: Bool {
isUsingPageViewController || displayMode == .twoUp || displayMode == .singlePage
}

var isSpreadEnabled: Bool {
displayMode == .twoUp || displayMode == .twoUpContinuous
}

/// Returns whether the document is currently zoomed to match the given
/// `fit`.
func isAtScaleFactor(for fit: Fit) -> Bool {
let scaleFactorToFit = scaleFactor(for: fit)
// 1% tolerance for floating point comparison
let tolerance: CGFloat = 0.01
return abs(scaleFactor - scaleFactorToFit) < tolerance
}

/// Calculates the appropriate scale factor based on the fit preference.
///
/// Only used in scroll mode, as the paginated mode doesn't support custom
/// scale factors without visual hiccups when swiping pages.
func scaleFactor(for fit: Fit) -> CGFloat {
// While a `width` fit works in scroll mode, the pagination mode has
// critical limitations when zooming larger than the page fit, so it
// does not support a `width` fit.
//
// - Visual snap: There is no API to pre-set the zoom scale for the next
// page. PDFView resets the scale per page, causing a visible snap
// when swiping. We don’t see the issue with edge taps.
// - Incorrect anchoring: When zooming larger than the page fit, the
// viewport centers vertically instead of showing the top. The API to
// fix this works in scroll mode but is ignored in paginated mode.
//
// So we only support a `page` fit in paginated mode.
if isPaginated {
return scaleFactorForSizeToFitVisiblePages
}

switch fit {
case .auto, .width:
// Use PDFKit's default auto-fit behavior
return scaleFactorForSizeToFit
case .page:
return scaleFactorForLargestPage
}
}

/// Calculates the scale factor to fit the visible pages (by area) to the
/// viewport.
private var scaleFactorForSizeToFitVisiblePages: CGFloat {
// The native `scaleFactorForSizeToFit` is incorrect when displaying
// paginated spreads, so we need to use a custom implementation.
if !isPaginated || !isSpreadEnabled {
scaleFactorForSizeToFit
} else {
calculateScale(
for: spreadSize(for: visiblePages),
viewSize: bounds.size,
insets: contentInset
)
}
}

/// Calculates the scale factor to fit the largest page or spread (by area)
/// to the viewport.
private var scaleFactorForLargestPage: CGFloat {
guard let document = document else {
return 1.0
}

// Check cache before expensive calculation
let viewSize = bounds.size
let insets = contentInset
if
let cached = cachedScaleFactorForLargestPage,
cached.document == ObjectIdentifier(document),
cached.viewSize == viewSize,
cached.contentInset == insets,
cached.spread == isSpreadEnabled,
cached.displaysAsBook == displaysAsBook
{
return cached.scaleFactor
}

var maxSize: CGSize = .zero
var maxArea: CGFloat = 0

if !isSpreadEnabled {
// No spreads: find largest individual page
for pageIndex in 0 ..< document.pageCount {
guard let page = document.page(at: pageIndex) else { continue }
let pageSize = page.bounds(for: displayBox).size
let area = pageSize.width * pageSize.height

if area > maxArea {
maxArea = area
maxSize = pageSize
}
}
} else {
// Spreads enabled: find largest spread
let pageCount = document.pageCount

if displaysAsBook, pageCount > 0 {
// First page displayed alone - check its size
if let firstPage = document.page(at: 0) {
let firstSize = firstPage.bounds(for: displayBox).size
let firstArea = firstSize.width * firstSize.height
if firstArea > maxArea {
maxArea = firstArea
maxSize = firstSize
}
}
}

// Check spreads (pairs of pages)
let startIndex = displaysAsBook ? 1 : 0
for pageIndex in stride(from: startIndex, to: pageCount, by: 2) {
let leftIndex = pageIndex
let rightIndex = pageIndex + 1

guard let leftPage = document.page(at: leftIndex) else { continue }

if rightIndex < pageCount, let rightPage = document.page(at: rightIndex) {
// Two-page spread
let currentSpreadSize = spreadSize(for: [leftPage, rightPage])
let spreadArea = currentSpreadSize.width * currentSpreadSize.height

if spreadArea > maxArea {
maxArea = spreadArea
maxSize = currentSpreadSize
}
} else {
// Last page alone (odd page count)
let leftSize = leftPage.bounds(for: displayBox).size
let singleArea = leftSize.width * leftSize.height
if singleArea > maxArea {
maxArea = singleArea
maxSize = leftSize
}
}
}
}

let scale = calculateScale(
for: maxSize,
viewSize: viewSize,
insets: insets
)

cachedScaleFactorForLargestPage = (
document: ObjectIdentifier(document),
scaleFactor: scale,
viewSize: viewSize,
contentInset: insets,
spread: isSpreadEnabled,
displaysAsBook: displaysAsBook
)
return scale
}

/// Cache for expensive largest page scale calculation.
private var cachedScaleFactorForLargestPage: (
document: ObjectIdentifier,
scaleFactor: CGFloat,
viewSize: CGSize,
contentInset: UIEdgeInsets,
spread: Bool,
displaysAsBook: Bool
)?

/// Calculates the combined size of pages laid out side-by-side horizontally.
private func spreadSize(for pages: [PDFPage]) -> CGSize {
var size = CGSize.zero
for page in pages {
let pageBounds = page.bounds(for: displayBox)
size.height = max(size.height, pageBounds.height)
size.width += pageBounds.width
}
return size
}

/// Calculates the scale factor needed to fit the given content size within
/// the available viewport, accounting for content insets.
private func calculateScale(
for contentSize: CGSize,
viewSize: CGSize,
insets: UIEdgeInsets
) -> CGFloat {
guard contentSize.width > 0, contentSize.height > 0 else {
return 1.0
}

let availableSize = CGSize(
width: viewSize.width - insets.left - insets.right,
height: viewSize.height - insets.top - insets.bottom
)

let widthScale = availableSize.width / contentSize.width
let heightScale = availableSize.height / contentSize.height

// Use the smaller scale to ensure both dimensions fit
return min(widthScale, heightScale)
}
}
25 changes: 18 additions & 7 deletions Sources/Navigator/PDF/PDFNavigatorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,12 @@ open class PDFNavigatorViewController:
super.viewWillTransition(to: size, with: coordinator)

if let pdfView = pdfView {
// Makes sure that the PDF is always properly scaled down when
// rotating the screen, if the user didn't zoom in.
let isAtMinScaleFactor = (pdfView.scaleFactor == pdfView.minScaleFactor)
// Makes sure that the PDF is always properly scaled when rotating
// the screen, if the user didn't set a custom zoom.
let isAtScaleFactor = pdfView.isAtScaleFactor(for: settings.fit)

coordinator.animate(alongsideTransition: { _ in
self.updateScaleFactors(zoomToFit: isAtMinScaleFactor)
self.updateScaleFactors(zoomToFit: isAtScaleFactor)

// Reset the PDF view to update the spread if needed.
if self.settings.spread == .auto {
Expand Down Expand Up @@ -403,7 +404,8 @@ open class PDFNavigatorViewController:

@objc private func visiblePagesDidChange() {
// In paginated mode, we want to refresh the scale factors to properly
// fit the newly visible pages.
// fit the newly visible pages. This is especially important for
// paginated spreads.
if !settings.scroll {
updateScaleFactors(zoomToFit: true)
}
Expand Down Expand Up @@ -489,11 +491,20 @@ open class PDFNavigatorViewController:
guard let pdfView = pdfView else {
return
}
pdfView.minScaleFactor = pdfView.scaleFactorForSizeToFit

let scaleFactorToFit = pdfView.scaleFactor(for: settings.fit)

if settings.scroll {
// Allow zooming out to 25% in scroll mode.
pdfView.minScaleFactor = 0.25
} else {
pdfView.minScaleFactor = scaleFactorToFit
}

pdfView.maxScaleFactor = 4.0

if zoomToFit {
pdfView.scaleFactor = pdfView.minScaleFactor
pdfView.scaleFactor = scaleFactorToFit
}
}

Expand Down
6 changes: 6 additions & 0 deletions Sources/Navigator/PDF/Preferences/PDFPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ public struct PDFPreferences: ConfigurablePreferences {
/// Background color behind the document pages.
public var backgroundColor: Color?

/// Method for fitting the pages within the viewport.
public var fit: Fit?

/// Indicates if the first page should be displayed in its own spread.
public var offsetFirstPage: Bool?

Expand Down Expand Up @@ -41,6 +44,7 @@ public struct PDFPreferences: ConfigurablePreferences {

public init(
backgroundColor: Color? = nil,
fit: Fit? = nil,
offsetFirstPage: Bool? = nil,
pageSpacing: Double? = nil,
readingProgression: ReadingProgression? = nil,
Expand All @@ -51,6 +55,7 @@ public struct PDFPreferences: ConfigurablePreferences {
) {
precondition(pageSpacing == nil || pageSpacing! >= 0)
self.backgroundColor = backgroundColor
self.fit = fit
self.offsetFirstPage = offsetFirstPage
self.pageSpacing = pageSpacing
self.readingProgression = readingProgression
Expand All @@ -63,6 +68,7 @@ public struct PDFPreferences: ConfigurablePreferences {
public func merging(_ other: PDFPreferences) -> PDFPreferences {
PDFPreferences(
backgroundColor: other.backgroundColor ?? backgroundColor,
fit: other.fit ?? fit,
offsetFirstPage: other.offsetFirstPage ?? offsetFirstPage,
pageSpacing: other.pageSpacing ?? pageSpacing,
readingProgression: other.readingProgression ?? readingProgression,
Expand Down
12 changes: 12 additions & 0 deletions Sources/Navigator/PDF/Preferences/PDFPreferencesEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ public final class PDFPreferencesEditor: StatefulPreferencesEditor<PDFPreference
isEffective: { $0.preferences.backgroundColor != nil }
)

/// Method for fitting the pages within the viewport.
///
/// Only effective when `scroll` is on.
public lazy var fit: AnyEnumPreference<Fit> =
enumPreference(
preference: \.fit,
setting: \.fit,
defaultEffectiveValue: defaults.fit ?? .auto,
isEffective: { $0.settings.scroll },
supportedValues: [.auto, .page, .width]
)

/// Indicates if the first page should be displayed in its own spread.
///
/// Only effective when `spread` is not off.
Expand Down
Loading