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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,21 @@ All notable changes to this project will be documented in this file. Take a look

## [Unreleased]

### Added

#### Navigator

* Added `VisualNavigatorDelegate.navigatorContentInset(_:)` to customize the content and safe-area insets used by the navigator.
* By default, the navigator uses the window's `safeAreaInsets`, which can cause content to shift when the status bar is shown or hidden (since those insets change). To avoid this, implement `navigatorContentInset(_:)` and return insets that remain stable across status bar visibility changes — for example, a top inset large enough to accommodate the maximum expected status bar height.

### Changed

#### Navigator

* `EPUBNavigatorViewController.Configuration.contentInset` now expects values that already include the safe area insets.
* If you previously supplied content-only margins, update them to add the safe-area values to preserve the same visible layout.
* Alternatively, implement `VisualNavigatorDelegate.navigatorContentInset(_:)` to compute and return the full insets (content + safe area), helping avoid layout shifts when system UI (e.g., the status bar) appears or disappears.

#### LCP

* The LCP License Document is now accessible via `publication.lcpLicense?.license`, even if the license validation fails with a status error or missing passphrase. This is useful for checking the end date of an expired license or renew a license.
Expand Down
5 changes: 1 addition & 4 deletions Sources/Navigator/EPUB/EPUBFixedSpreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,7 @@ final class EPUBFixedSpreadView: EPUBSpreadView {
return
}

// We use the window's safeAreaInsets instead of the view's because we
// only want to take into account the device notch and status bar, not
// the application's bars.
var insets = window?.safeAreaInsets ?? .zero
var insets = delegate?.spreadViewContentInset(self) ?? .zero

// Use the same insets on the left and right side (the largest one) to
// keep the pages centered on the screen even if the notches are not
Expand Down
44 changes: 39 additions & 5 deletions Sources/Navigator/EPUB/EPUBNavigatorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,17 @@ open class EPUBNavigatorViewController: InputObservableViewController,
/// Disables horizontal page turning when scroll is enabled.
public var disablePageTurnsWhileScrolling: Bool

/// Content insets used to add some vertical margins around reflowable EPUB publications.
/// The insets can be configured for each size class to allow smaller margins on compact
/// screens.
/// Content insets used to add some vertical margins around reflowable
/// EPUB publications. Note that the margins include the safe area
/// insets. To avoid any "jump" when toggling the status bar, provide
/// values large enough.
///
/// The insets can be configured for each size class to allow smaller
/// margins on compact screens.
///
/// For more control, implement the `navigatorContentInset()` delegate
/// method, which takes precedence over this configuration property
/// when implemented.
public var contentInset: [UIUserInterfaceSizeClass: EPUBContentInsets]

/// Number of positions (as in `Publication.positionList`) to preload before the current page.
Expand Down Expand Up @@ -96,8 +104,8 @@ open class EPUBNavigatorViewController: InputObservableViewController,
editingActions: [EditingAction] = EditingAction.defaultActions,
disablePageTurnsWhileScrolling: Bool = false,
contentInset: [UIUserInterfaceSizeClass: EPUBContentInsets] = [
.compact: (top: 20, bottom: 20),
.regular: (top: 44, bottom: 44),
.compact: (top: 34, bottom: 34),
.regular: (top: 62, bottom: 62),
],
preloadPreviousPositionCount: Int = 2,
preloadNextPositionCount: Int = 6,
Expand All @@ -118,6 +126,13 @@ open class EPUBNavigatorViewController: InputObservableViewController,
self.readiumCSSRSProperties = readiumCSSRSProperties
self.debugState = debugState
}

func contentInset(for sizeClass: UIUserInterfaceSizeClass) -> EPUBContentInsets {
contentInset[sizeClass]
?? contentInset[.regular]
?? contentInset[.unspecified]
?? (top: 0, bottom: 0)
}
}

public weak var delegate: EPUBNavigatorDelegate?
Expand Down Expand Up @@ -1006,6 +1021,25 @@ extension EPUBNavigatorViewController: EPUBNavigatorViewModelDelegate {
}

extension EPUBNavigatorViewController: EPUBSpreadViewDelegate {
func spreadViewContentInset(_ spreadView: EPUBSpreadView) -> UIEdgeInsets {
if let inset = delegate?.navigatorContentInset(self) {
return inset
}

// We use the window's safeAreaInsets instead of the view's because we
// only want to take into account the device notch and status bar, not
// the application's bars.
var insets = view.window?.safeAreaInsets ?? .zero

if publication.metadata.layout != .fixed {
let configInset = config.contentInset(for: view.traitCollection.verticalSizeClass)
insets.top = max(insets.top, configInset.top)
insets.bottom = max(insets.bottom, configInset.bottom)
}

return insets
}

func spreadViewDidLoad(_ spreadView: EPUBSpreadView) async {
let templates = config.decorationTemplates.reduce(into: [:]) { styles, item in
styles[item.key.rawValue] = item.value.json
Expand Down
22 changes: 4 additions & 18 deletions Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,30 +86,16 @@ final class EPUBReflowableSpreadView: EPUBSpreadView {
}

private func updateContentInset() {
// We use the window's safeAreaInsets instead of the view's because we
// only want to take into account the device notch and status bar, not
// the application's bars.
let safeAreaInsets = window?.safeAreaInsets ?? .zero
let contentInset = delegate?.spreadViewContentInset(self) ?? .zero

if viewModel.scroll {
topConstraint.constant = 0
bottomConstraint.constant = 0
scrollView.contentInset = UIEdgeInsets(top: safeAreaInsets.top, left: 0, bottom: safeAreaInsets.bottom, right: 0)
scrollView.contentInset = contentInset

} else {
let contentInset = viewModel.config.contentInset
var insets = contentInset[traitCollection.verticalSizeClass]
?? contentInset[.regular]
?? contentInset[.unspecified]
?? (top: 0, bottom: 0)

// Increases the insets by the window's safe area insets area to
// make sure that the content is not overlapped by the screen notch.
insets.top += safeAreaInsets.top
insets.bottom += safeAreaInsets.bottom

topConstraint.constant = insets.top
bottomConstraint.constant = -insets.bottom
topConstraint.constant = contentInset.top
bottomConstraint.constant = -contentInset.bottom
scrollView.contentInset = .zero
}
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/Navigator/EPUB/EPUBSpreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import SwiftSoup
@preconcurrency import WebKit

protocol EPUBSpreadViewDelegate: AnyObject {
/// Returns the content inset the spread view should use.
func spreadViewContentInset(_ spreadView: EPUBSpreadView) -> UIEdgeInsets

/// Called when the spread view finished loading.
func spreadViewDidLoad(_ spreadView: EPUBSpreadView) async

Expand Down
18 changes: 12 additions & 6 deletions Sources/Navigator/PDF/PDFDocumentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,21 @@
import Foundation
import PDFKit

protocol PDFDocumentViewDelegate: AnyObject {
func pdfDocumentViewContentInset(_ pdfDocumentView: PDFDocumentView) -> UIEdgeInsets?
}

public final class PDFDocumentView: PDFView {
var editingActions: EditingActionsController
private weak var documentViewDelegate: PDFDocumentViewDelegate?

init(frame: CGRect, editingActions: EditingActionsController) {
init(
frame: CGRect,
editingActions: EditingActionsController,
documentViewDelegate: PDFDocumentViewDelegate
) {
self.editingActions = editingActions
self.documentViewDelegate = documentViewDelegate

super.init(frame: frame)

Expand Down Expand Up @@ -40,11 +50,7 @@ public final class PDFDocumentView: PDFView {
}

private func updateContentInset() {
// We use the window's safeAreaInsets instead of the view's because we
// only want to take into account the device notch and status bar, not
// the application's bars.
let insets = window?.safeAreaInsets ?? .zero

let insets = documentViewDelegate?.pdfDocumentViewContentInset(self) ?? window?.safeAreaInsets ?? .zero
firstScrollView?.contentInset.top = insets.top
firstScrollView?.contentInset.bottom = insets.bottom
}
Expand Down
12 changes: 11 additions & 1 deletion Sources/Navigator/PDF/PDFNavigatorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,11 @@ open class PDFNavigatorViewController:
}

currentResourceIndex = nil
let pdfView = PDFDocumentView(frame: view.bounds, editingActions: editingActions)
let pdfView = PDFDocumentView(
frame: view.bounds,
editingActions: editingActions,
documentViewDelegate: self
)
self.pdfView = pdfView
pdfView.delegate = self
pdfView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
Expand Down Expand Up @@ -659,6 +663,12 @@ extension PDFNavigatorViewController: PDFViewDelegate {
}
}

extension PDFNavigatorViewController: PDFDocumentViewDelegate {
func pdfDocumentViewContentInset(_ pdfDocumentView: PDFDocumentView) -> UIEdgeInsets? {
delegate?.navigatorContentInset(self)
}
}

extension PDFNavigatorViewController: EditingActionsControllerDelegate {
func editingActionsDidPreventCopy(_ editingActions: EditingActionsController) {
delegate?.navigator(self, presentError: .copyForbidden)
Expand Down
20 changes: 18 additions & 2 deletions Sources/Navigator/VisualNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,21 @@ public struct VisualNavigatorPresentation {
}

@MainActor public protocol VisualNavigatorDelegate: NavigatorDelegate {
/// Returns the content insets that the navigator applies to its view.
///
/// Implement this method to customize the margins around the publication
/// content and to control which areas may be covered by the app's UI or
/// system bars.
///
/// Consider the view's safe area insets to prevent notches, the status bar,
/// or other overlays from obscuring the content.
///
/// - Returns: The insets to apply, or `nil` to use the navigator’s default behavior.
func navigatorContentInset(_ navigator: VisualNavigator) -> UIEdgeInsets?

/// Called when the navigator presentation changed, for example after
/// applying a new set of preferences.
func navigator(_ navigator: Navigator, presentationDidChange presentation: VisualNavigatorPresentation)
func navigator(_ navigator: VisualNavigator, presentationDidChange presentation: VisualNavigatorPresentation)

/// Called when the user tapped the publication, and it didn't trigger any
/// internal action. The point is relative to the navigator's view.
Expand All @@ -110,7 +122,11 @@ public struct VisualNavigatorPresentation {
}

public extension VisualNavigatorDelegate {
func navigator(_ navigator: Navigator, presentationDidChange presentation: VisualNavigatorPresentation) {
func navigatorContentInset(_ navigator: VisualNavigator) -> UIEdgeInsets? {
nil
}

func navigator(_ navigator: VisualNavigator, presentationDidChange presentation: VisualNavigatorPresentation) {
// Optional
}

Expand Down