Skip to content
Draft
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
32 changes: 32 additions & 0 deletions Sources/Navigator/EPUB/CustomTypeSpreadViewWrapper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// Copyright 2025 Readium Foundation. All rights reserved.
// Use of this source code is governed by the BSD-style license
// available in the top-level LICENSE file of the project.
//

import Foundation
import ReadiumShared
import UIKit

class CustomTypeSpreadViewWrapper: UIView, Loggable, PageView, EPUBSpreadViewContainer {
let spread: EPUBSpread

init(spread: EPUBSpread) {
self.spread = spread
super.init(frame: .zero)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func progression(in index: ReadingOrder.Index) -> ClosedRange<Double> {
// To be overridden in subclasses if the resource supports a progression.
0 ... 1
}

func go(to location: PageLocation) async {
// Custom layout resources are always fully visible so we don't use the location
}
}
127 changes: 126 additions & 1 deletion Sources/Navigator/EPUB/EPUBNavigatorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,18 @@ import WebKit
// MARK: - WebView Customization

func navigator(_ navigator: EPUBNavigatorViewController, setupUserScripts userContentController: WKUserContentController)

func navigator(_ navigator: EPUBNavigatorViewController, viewControllersForReadingOrderLinks links: [Link]) -> (left: UIViewController?, center: UIViewController?, right: UIViewController?)
}

public extension EPUBNavigatorDelegate {
func navigator(_ navigator: EPUBNavigatorViewController, viewportDidChange viewport: EPUBNavigatorViewController.Viewport?) {}

func navigator(_ navigator: EPUBNavigatorViewController, setupUserScripts userContentController: WKUserContentController) {}

func navigator(_ navigator: EPUBNavigatorViewController, viewControllersForReadingOrderLinks links: [Link]) -> (left: UIViewController?, center: UIViewController?, right: UIViewController?) {
(left: nil, center: nil, right: nil)
}
}

public typealias EPUBContentInsets = (top: CGFloat, bottom: CGFloat)
Expand Down Expand Up @@ -670,7 +676,7 @@ open class EPUBNavigatorViewController: InputObservableViewController,
return (pendingLocator, nil)
}

guard let spreadView = paginationView?.currentView as? EPUBSpreadView else {
guard let spreadView = paginationView?.currentView as? EPUBSpreadViewContainer else {
return (nil, nil)
}

Expand Down Expand Up @@ -1251,6 +1257,12 @@ extension EPUBNavigatorViewController: EPUBSpreadViewDelegate {
}
}

func spreadView(_ spreadView: EPUBSpreadView, didScrollIn direction: ScrollDirection) {
if paginationView?.currentView == spreadView {
delegate?.navigator(self, didScrollIn: direction)
}
}

func spreadView(_ spreadView: EPUBSpreadView, present viewController: UIViewController) {
present(viewController, animated: true)
}
Expand All @@ -1277,6 +1289,21 @@ extension EPUBNavigatorViewController: EditingActionsControllerDelegate {
extension EPUBNavigatorViewController: PaginationViewDelegate {
func paginationView(_ paginationView: PaginationView, pageViewAtIndex index: Int) -> (UIView & PageView)? {
let spread = spreads[index]

let linksForSpread: [Link] = spread.readingOrderIndices.map { index in
publication.readingOrder[index]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, publication.readingOrder is an array, right? If so, you might want to use safe index here.

}

let customVCs = delegate?.navigator(self, viewControllersForReadingOrderLinks: linksForSpread)
// at least one page in spread is custom view
if let customVCs, customVCs.left != nil || customVCs.right != nil || customVCs.center != nil {
return createCustomSpread(spread: spread, customViewControllers: customVCs, linksForSpread: linksForSpread)
} else {
return createSpreadView(spread: spread)
}
}

private func createSpreadView(spread: EPUBSpread) -> EPUBSpreadView {
let spreadViewType = (publication.metadata.layout == .fixed) ? EPUBFixedSpreadView.self : EPUBReflowableSpreadView.self
let spreadView = spreadViewType.init(
viewModel: viewModel,
Expand All @@ -1303,3 +1330,101 @@ extension EPUBNavigatorViewController: PaginationViewDelegate {
spreads[index].positionCount(in: readingOrder, positionsByReadingOrder: positionsByReadingOrder)
}
}

extension EPUBNavigatorViewController {
private func createCustomSpread(
spread: EPUBSpread,
customViewControllers customVCs: (left: UIViewController?, center: UIViewController?, right: UIViewController?),
linksForSpread: [Link]
) -> (UIView & PageView)? {
let customTypeSpreadContainer = CustomTypeSpreadViewWrapper(spread: spread)

if let left = customVCs.left {
customTypeSpreadContainer.addSubview(left.view)
addChild(left)
NSLayoutConstraint.activate([
left.view.leadingAnchor.constraint(equalTo: customTypeSpreadContainer.leadingAnchor),
left.view.topAnchor.constraint(equalTo: customTypeSpreadContainer.topAnchor),
left.view.bottomAnchor.constraint(equalTo: customTypeSpreadContainer.bottomAnchor),
left.view.widthAnchor.constraint(equalTo: customTypeSpreadContainer.widthAnchor, multiplier: 0.5),
])
}

if let center = customVCs.center {
customTypeSpreadContainer.addSubview(center.view)
addChild(center)
NSLayoutConstraint.activate([
center.view.leadingAnchor.constraint(equalTo: customTypeSpreadContainer.leadingAnchor),
center.view.topAnchor.constraint(equalTo: customTypeSpreadContainer.topAnchor),
center.view.bottomAnchor.constraint(equalTo: customTypeSpreadContainer.bottomAnchor),
center.view.trailingAnchor.constraint(equalTo: customTypeSpreadContainer.trailingAnchor),
])
}

if let right = customVCs.right {
customTypeSpreadContainer.addSubview(right.view)
addChild(right)
NSLayoutConstraint.activate([
right.view.leadingAnchor.constraint(equalTo: customTypeSpreadContainer.leadingAnchor),
right.view.topAnchor.constraint(equalTo: customTypeSpreadContainer.topAnchor),
right.view.bottomAnchor.constraint(equalTo: customTypeSpreadContainer.bottomAnchor),
right.view.widthAnchor.constraint(equalTo: customTypeSpreadContainer.widthAnchor, multiplier: 0.5),
])
}

// handle case when only one page in two pages spread is custom view
if linksForSpread.count == 2, customVCs.center == nil {
if customVCs.left == nil {
let spreadView =
createSpreadView(
spread: EPUBSpread(
spread: false,
readingOrderIndices: spread.readingOrderIndices.lowerBound ... spread.readingOrderIndices.lowerBound,
readingProgression: spread.readingProgression
)
)
customTypeSpreadContainer.addSubview(spreadView)
NSLayoutConstraint.activate([
spreadView.leadingAnchor.constraint(equalTo: customTypeSpreadContainer.leadingAnchor),
spreadView.topAnchor.constraint(equalTo: customTypeSpreadContainer.topAnchor),
spreadView.bottomAnchor.constraint(equalTo: customTypeSpreadContainer.bottomAnchor),
spreadView.widthAnchor.constraint(equalTo: customTypeSpreadContainer.widthAnchor, multiplier: 0.5),
])
} else if customVCs.right == nil {
let spreadView =
createSpreadView(
spread: EPUBSpread(
spread: false,
readingOrderIndices: spread.readingOrderIndices.upperBound ... spread.readingOrderIndices.upperBound,
readingProgression: spread.readingProgression
)
)
customTypeSpreadContainer.addSubview(spreadView)
NSLayoutConstraint.activate([
spreadView.trailingAnchor.constraint(equalTo: customTypeSpreadContainer.trailingAnchor),
spreadView.topAnchor.constraint(equalTo: customTypeSpreadContainer.topAnchor),
spreadView.bottomAnchor.constraint(equalTo: customTypeSpreadContainer.bottomAnchor),
spreadView.widthAnchor.constraint(equalTo: customTypeSpreadContainer.widthAnchor, multiplier: 0.5),
])
}
} else {
let spreadView =
createSpreadView(
spread: EPUBSpread(
spread: false,
readingOrderIndices: spread.readingOrderIndices.lowerBound ... spread.readingOrderIndices.upperBound,
readingProgression: spread.readingProgression
)
)
customTypeSpreadContainer.addSubview(spreadView)
NSLayoutConstraint.activate([
spreadView.leadingAnchor.constraint(equalTo: customTypeSpreadContainer.leadingAnchor),
spreadView.topAnchor.constraint(equalTo: customTypeSpreadContainer.topAnchor),
spreadView.bottomAnchor.constraint(equalTo: customTypeSpreadContainer.bottomAnchor),
spreadView.widthAnchor.constraint(equalTo: customTypeSpreadContainer.widthAnchor),
])
}

return customTypeSpreadContainer
}
}
17 changes: 17 additions & 0 deletions Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,8 @@ final class EPUBReflowableSpreadView: EPUBSpreadView {
private var progression: ClosedRange<Double>?
// To check if a progression change was cancelled or not.
private var previousProgression: ClosedRange<Double>?
// Previous scroll offset for determining scroll direction.
private var previousScrollOffset: CGPoint?

// Called by the javascript code to notify that scrolling ended.
private func progressionDidChange(_ body: Any) {
Expand Down Expand Up @@ -383,5 +385,20 @@ final class EPUBReflowableSpreadView: EPUBSpreadView {
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
super.scrollViewDidScroll(scrollView)
setNeedsNotifyPagesDidChange()

let currentOffset = scrollView.contentOffset
if let previousOffset = previousScrollOffset {
// Determine direction based on scroll axis (vertical vs horizontal)
let direction: ScrollDirection
if viewModel.scroll, !viewModel.verticalText {
// Vertical scrolling mode
direction = currentOffset.y > previousOffset.y ? .forward : .backward
} else {
// Horizontal scrolling/pagination mode
direction = currentOffset.x > previousOffset.x ? .forward : .backward
}
delegate?.spreadView(self, didScrollIn: direction)
}
previousScrollOffset = currentOffset
}
}
5 changes: 4 additions & 1 deletion Sources/Navigator/EPUB/EPUBSpreadView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ protocol EPUBSpreadViewDelegate: AnyObject {
/// Called when the pages visible in the spread changed.
func spreadViewPagesDidChange(_ spreadView: EPUBSpreadView)

/// Called when the user scrolls through the content.
func spreadView(_ spreadView: EPUBSpreadView, didScrollIn direction: ScrollDirection)

/// Called when the spread view needs to present a view controller.
func spreadView(_ spreadView: EPUBSpreadView, present viewController: UIViewController)

Expand All @@ -43,7 +46,7 @@ protocol EPUBSpreadViewDelegate: AnyObject {
func spreadViewDidTerminate()
}

class EPUBSpreadView: UIView, Loggable, PageView {
class EPUBSpreadView: UIView, Loggable, PageView, EPUBSpreadViewContainer {
weak var delegate: EPUBSpreadViewDelegate?
let viewModel: EPUBNavigatorViewModel
let spread: EPUBSpread
Expand Down
12 changes: 12 additions & 0 deletions Sources/Navigator/EPUB/EPUBSpreadViewContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// Copyright 2025 Readium Foundation. All rights reserved.
// Use of this source code is governed by the BSD-style license
// available in the top-level LICENSE file of the project.
//

import ReadiumShared

protocol EPUBSpreadViewContainer {
var spread: EPUBSpread { get }
func progression(in index: ReadingOrder.Index) -> ClosedRange<Double>
}
18 changes: 18 additions & 0 deletions Sources/Navigator/VisualNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ public struct VisualNavigatorPresentation {
}
}

/// Direction of a scroll event.
public enum ScrollDirection {
/// Scrolling up (vertical) or left (horizontal).
case backward
/// Scrolling down (vertical) or right (horizontal).
case forward
}

@MainActor public protocol VisualNavigatorDelegate: NavigatorDelegate {
/// Returns the content insets that the navigator applies to its view.
///
Expand Down Expand Up @@ -119,6 +127,12 @@ public struct VisualNavigatorPresentation {
/// Return `true` to navigate to the link, or `false` if you intend to
/// present the link yourself
func navigator(_ navigator: VisualNavigator, shouldNavigateToLink link: Link) -> Bool

/// Called when the user scrolls through the content.
///
/// - Parameter direction: The direction of the scroll (forward = down/right,
/// backward = up/left).
func navigator(_ navigator: VisualNavigator, didScrollIn direction: ScrollDirection)
}

public extension VisualNavigatorDelegate {
Expand All @@ -145,4 +159,8 @@ public extension VisualNavigatorDelegate {
func navigator(_ navigator: VisualNavigator, shouldNavigateToLink link: Link) -> Bool {
true
}

func navigator(_ navigator: VisualNavigator, didScrollIn direction: ScrollDirection) {
// Optional
}
}
14 changes: 14 additions & 0 deletions docs/Guides/Navigator/EPUB native spread layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# EPUBNavigatorDelegate

Readium allows rendering EPUB spine elements in a custom way (e.g. PDF is rendered using PDFKit instead of being loaded in WKWebView).
Client can provide custom view for given spread using
```swift
func navigator(_ navigator: EPUBNavigatorViewController, viewControllersForReadingOrderLinks links: [Link]) -> (left: UIViewController?, center: UIViewController?, right: UIViewController?)
```
delegate method.

Returned tuple contains left and right pages if spread has 2 pages. Center property is used in case spread has 1 page.
In case any of spread pages doesn't require custom view ```nil``` is returned for such page, and readium will fallback to webview rendering.

```links``` contains array of EPUB spine element links (if spread has 2 pages it will have 2 links, otherwise 1 link).
EPUB document could hold JSON resource which contains mapping of spine element links to metadata model needed to perform custom rendering (e.g. asset hrefs, custom view type info ...)