diff --git a/Sources/Navigator/EPUB/CustomTypeSpreadViewWrapper.swift b/Sources/Navigator/EPUB/CustomTypeSpreadViewWrapper.swift new file mode 100644 index 0000000000..78b4b50bce --- /dev/null +++ b/Sources/Navigator/EPUB/CustomTypeSpreadViewWrapper.swift @@ -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 { + // 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 + } +} diff --git a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift index 382a080839..8ff2ade194 100644 --- a/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift +++ b/Sources/Navigator/EPUB/EPUBNavigatorViewController.swift @@ -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) @@ -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) } @@ -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) } @@ -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] + } + + 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, @@ -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 + } +} diff --git a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift index 1436541904..617f7e5bd0 100644 --- a/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBReflowableSpreadView.swift @@ -320,6 +320,8 @@ final class EPUBReflowableSpreadView: EPUBSpreadView { private var progression: ClosedRange? // To check if a progression change was cancelled or not. private var previousProgression: ClosedRange? + // 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) { @@ -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 } } diff --git a/Sources/Navigator/EPUB/EPUBSpreadView.swift b/Sources/Navigator/EPUB/EPUBSpreadView.swift index fbdd5abd5e..844ee84683 100644 --- a/Sources/Navigator/EPUB/EPUBSpreadView.swift +++ b/Sources/Navigator/EPUB/EPUBSpreadView.swift @@ -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) @@ -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 diff --git a/Sources/Navigator/EPUB/EPUBSpreadViewContainer.swift b/Sources/Navigator/EPUB/EPUBSpreadViewContainer.swift new file mode 100644 index 0000000000..26ef9a1060 --- /dev/null +++ b/Sources/Navigator/EPUB/EPUBSpreadViewContainer.swift @@ -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 +} diff --git a/Sources/Navigator/VisualNavigator.swift b/Sources/Navigator/VisualNavigator.swift index 6c9a508e5c..b6f6cee347 100644 --- a/Sources/Navigator/VisualNavigator.swift +++ b/Sources/Navigator/VisualNavigator.swift @@ -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. /// @@ -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 { @@ -145,4 +159,8 @@ public extension VisualNavigatorDelegate { func navigator(_ navigator: VisualNavigator, shouldNavigateToLink link: Link) -> Bool { true } + + func navigator(_ navigator: VisualNavigator, didScrollIn direction: ScrollDirection) { + // Optional + } } diff --git a/docs/Guides/Navigator/EPUB native spread layout.md b/docs/Guides/Navigator/EPUB native spread layout.md new file mode 100644 index 0000000000..9f8614fb97 --- /dev/null +++ b/docs/Guides/Navigator/EPUB native spread layout.md @@ -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 ...)