ReadViewSDK/Sources/RDEpubReaderView/ReaderView/RDEpubReaderView.swift
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView
- Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/
- Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec
- Update Podfile, demo project, and CocoaPods config for new pod name
- Delete old RDReaderView pod support files from ReadViewDemo/Pods
- Add new RDEpubReaderView pod support files
- Update documentation (API ref, architecture, UML, conventions, etc.)
- Add FixedLayoutRotationTests
- Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
2026-07-10 19:44:53 +09:00

962 lines
36 KiB
Swift

import UIKit
enum RDEpubReaderTapDebug {
private static let enabledArguments: Set<String> = [
"--demo-tap-debug",
"--demo-reader-interaction-debug"
]
static var isEnabled: Bool {
#if DEBUG
return true
#else
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains(where: { enabledArguments.contains($0) }) {
return true
}
let environment = ProcessInfo.processInfo.environment
return environment["RDEpubReader_TAP_DEBUG"] == "1"
#endif
}
static func log(_ scope: String, _ message: String) {
guard isEnabled else { return }
let threadRole = Thread.isMainThread ? "main" : "bg"
let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
print("[RDEpubReaderTap][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)")
}
static func describe(_ point: CGPoint) -> String {
"(\(format(point.x)), \(format(point.y)))"
}
static func describe(_ rect: CGRect) -> String {
"(x:\(format(rect.origin.x)), y:\(format(rect.origin.y)), w:\(format(rect.size.width)), h:\(format(rect.size.height)))"
}
static func describe(_ view: UIView?) -> String {
guard let view else { return "nil" }
let identifier = view.accessibilityIdentifier ?? "nil"
return "\(type(of: view))(addr=\(Unmanaged.passUnretained(view).toOpaque()), id=\(identifier))"
}
private static func format(_ value: CGFloat) -> String {
String(format: "%.1f", value)
}
}
public class RDEpubReaderView: UIView {
enum TapEvent {
case none
case left
case center
case right
}
lazy var pageViewController: UIPageViewController = {
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
pageVC.delegate = self
pageVC.dataSource = self
return pageVC
}()
lazy var layout: RDEpubReaderFlowLayout = {
let layout = RDEpubReaderFlowLayout(displayType: .horizontalScroll)
layout.dataSource = self
layout.delegate = self
return layout
}()
lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = UIColor.clear
collectionView.accessibilityIdentifier = "epub.reader.paging"
return collectionView
}()
private let spreadResolver = RDEpubReaderSpreadResolver()
private let tapRegionHandler = RDEpubReaderTapRegionHandler()
let preloadController = RDEpubReaderPreloadController()
var pagingController = RDEpubReaderPagingController()
public var currentPage: Int = -1 {
didSet {
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDEpubReaderDelegate.pageNum(readerView:pageNum:))) {
delegate.pageNum(readerView: self, pageNum: currentPage)
}
}
}
private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = {
let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction(tap:)))
tap.delegate = self
return tap
}()
private var tapEvent: TapEvent = .none {
didSet {
let isRTL = pageDirection == .rightToLeft
switch tapEvent {
case .left:
if currentDisplayType != .pageCurl {
if isRTL { goNextPage() } else { goPreviousPage() }
}
case .right:
if currentDisplayType != .pageCurl {
if isRTL { goPreviousPage() } else { goNextPage() }
}
case .center:
tapCenter()
default:
break
}
}
}
private func goNextPage() {
let totalPages = numberOfPages()
if let target = spreadResolver.nextPage(
from: currentPage,
totalPages: totalPages,
pagesPerScreen: pagesPerScreen,
coverPageIndex: coverPageIndex,
forward: true
) {
transitionToPage(pageNum: target, animated: true)
}
}
private func goPreviousPage() {
let totalPages = numberOfPages()
if let target = spreadResolver.nextPage(
from: currentPage,
totalPages: totalPages,
pagesPerScreen: pagesPerScreen,
coverPageIndex: coverPageIndex,
forward: false
) {
transitionToPage(pageNum: target, animated: true)
}
}
private let legacyDataSourceAdapter = RDEpubReaderLegacyDataSourceAdapter()
public weak var dataSource: RDEpubReaderDataSource? {
didSet {
legacyDataSourceAdapter.dataSource = dataSource
}
}
public weak var pageProvider: RDEpubReaderPageProvider?
public weak var delegate: RDEpubReaderDelegate? = nil
public var currentDisplayType: RDEpubReaderView.DisplayType = .pageCurl
public var toolViewAnimationDuration: TimeInterval = 0.3
var onToolViewVisibilityChanged: ((Bool) -> Void)?
var searchBarView: UIView?
public var landscapeDualPageEnabled: Bool = false
public var pageDirection: RDEpubReaderView.PageDirection = .leftToRight
public var coverPageIndex: Int? = nil
private var resolvedPageProvider: RDEpubReaderPageProvider? {
pageProvider ?? legacyDataSourceAdapter
}
func numberOfPages() -> Int {
resolvedPageProvider?.numberOfPages(in: self) ?? 0
}
func pageReuseIdentifier(for pageNum: Int) -> String? {
resolvedPageProvider?.pageIdentifier?(in: self, index: pageNum)
}
func contentViewForPage(_ pageNum: Int, reusableView: UIView?) -> UIView {
resolvedPageProvider?.readerView(self, viewForPageAt: pageNum, reusableView: reusableView) ?? UIView()
}
private func resolvedTopChromeView() -> UIView? {
resolvedPageProvider?.readerViewTopChrome?(self)
}
private func resolvedBottomChromeView() -> UIView? {
resolvedPageProvider?.readerViewBottomChrome?(self)
}
func refreshToolViewsFromProviderIfNeeded() {
if topToolView == nil {
topToolView = resolvedTopChromeView()
}
if bottomToolView == nil {
bottomToolView = resolvedBottomChromeView()
}
}
private var hasCoverPage: Bool {
return coverPageIndex != nil
}
var isLandscape: Bool {
return bounds.width > bounds.height
}
public var pagesPerScreen: Int {
if !landscapeDualPageEnabled { return 1 }
if currentDisplayType == .verticalScroll { return 1 }
return isLandscape ? 2 : 1
}
public func isFullScreenPage(_ pageNum: Int) -> Bool {
spreadResolver.isFullScreenPage(
pageNum,
landscapeDualPageEnabled: landscapeDualPageEnabled,
isLandscape: isLandscape,
coverPageIndex: coverPageIndex
)
}
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
let totalPages = numberOfPages()
return spreadResolver.dualPagePair(
for: pageNum,
totalPages: totalPages,
coverPageIndex: coverPageIndex
)
}
private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? {
let totalPages = numberOfPages()
return spreadResolver.adjacentDualPage(
from: pageNum,
totalPages: totalPages,
coverPageIndex: coverPageIndex,
forward: forward
)
}
private func clampedPageNumber(_ pageNum: Int) -> Int? {
let totalPages = numberOfPages()
guard totalPages > 0 else { return nil }
return min(max(pageNum, 0), totalPages - 1)
}
private var previousIsLandscape: Bool?
private var pendingIsLandscape: Bool?
private var isWaitingForOrientationTransitionCompletion = false
var contentViews = [String : UIView.Type]()
var willPreviousTransitionToViewController: UIViewController? = nil
var willNextTransitionToViewController: UIViewController? = nil
var willTransitionToViewController: UIViewController? = nil
var topToolView: UIView?
var bottomToolView: UIView?
var topToolViewHeightConstraint: NSLayoutConstraint?
var bottomToolViewHeightConstraint: NSLayoutConstraint?
var isShowToolView: Bool = false
private var isTransitioning: Bool {
get { pagingController.isTransitioning }
set { pagingController.isTransitioning = newValue }
}
private var didBuildUI: Bool {
get { pagingController.didBuildUI }
set { pagingController.didBuildUI = newValue }
}
var predictedPageDirection: Bool?
public var preloadRadius: Int {
get { preloadController.radius }
set { preloadController.radius = newValue }
}
static let blankPageNum = Int.max
static let blankEndPageNum = Int.max - 1
private typealias PageTransitionRequest = RDEpubReaderPagingController.PageTransitionRequest
private let registeredSelectionLongPressRecognizers = NSHashTable<UILongPressGestureRecognizer>.weakObjects()
private let selectionTapSuppressedContentViews = NSHashTable<UIView>.weakObjects()
private let selectionPagingSuppressedContentViews = NSHashTable<UIView>.weakObjects()
private var isPagingInteractionSuppressed = false
private var pendingTransitionRequest: PageTransitionRequest? {
get { pagingController.pendingTransitionRequest }
set { pagingController.pendingTransitionRequest = newValue }
}
var isPageCurlTransitioning: Bool {
currentDisplayType == .pageCurl && pagingController.isTransitioning
}
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content"
isAccessibilityElement = false
}
public override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
preloadController.setHostFrame(bounds)
updateToolViewHeightConstraintsIfNeeded()
let nowLandscape = isLandscape
if let prev = previousIsLandscape, prev != nowLandscape {
previousIsLandscape = nowLandscape
scheduleOrientationChange(isNowLandscape: nowLandscape)
} else if previousIsLandscape == nil {
previousIsLandscape = nowLandscape
layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape
}
}
private func scheduleOrientationChange(isNowLandscape: Bool) {
pendingIsLandscape = isNowLandscape
// UIPageViewController asserts if it is detached/rebuilt while UIKit is
// still executing its own rotation callbacks. Rebuild the single/dual
// page controller only after the enclosing transition has completed.
if let coordinator = ss_superViewController?.transitionCoordinator,
coordinator.isAnimated {
guard !isWaitingForOrientationTransitionCompletion else { return }
isWaitingForOrientationTransitionCompletion = true
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
guard let self else { return }
self.isWaitingForOrientationTransitionCompletion = false
self.applyPendingOrientationChange()
}
return
}
DispatchQueue.main.async { [weak self] in
self?.applyPendingOrientationChange()
}
}
private func applyPendingOrientationChange() {
guard let isNowLandscape = pendingIsLandscape else { return }
pendingIsLandscape = nil
orientationChanged(isNowLandscape: isNowLandscape)
}
private func orientationChanged(isNowLandscape: Bool) {
let savedPage = max(0, currentPage)
delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape)
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
layout.coverPageIndex = coverPageIndex
switch currentDisplayType {
case .pageCurl:
rebuildPageViewController()
transitionToPage(pageNum: savedPage)
primePageCache(around: savedPage, preferredForward: predictedPageDirection)
default:
UIView.performWithoutAnimation {
collectionView.collectionViewLayout.invalidateLayout()
collectionView.reloadData()
collectionView.layoutIfNeeded()
let totalPages = numberOfPages()
let safePage = min(savedPage, max(0, totalPages - 1))
let targetOffset = layout.currentContentOffset(count: safePage)
collectionView.setContentOffset(targetOffset, animated: false)
primePageCache(around: safePage, preferredForward: predictedPageDirection)
}
}
}
private func createPageViewController(isDualPage: Bool) -> UIPageViewController {
let options: [UIPageViewController.OptionsKey: Any]?
if isDualPage {
options = [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)]
} else {
options = nil
}
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
pageVC.delegate = self
pageVC.dataSource = self
pageVC.isDoubleSided = isDualPage
return pageVC
}
private func rebuildPageViewController() {
detachPageViewControllerIfNeeded()
let isDualPage = landscapeDualPageEnabled && isLandscape
let pageVC = createPageViewController(isDualPage: isDualPage)
pageViewController = pageVC
attachPageViewControllerIfNeeded()
}
private func detectPageViewControllerFault(_ pageVC: UIPageViewController) -> Bool {
guard currentDisplayType == .pageCurl else { return false }
let expectedCount = (landscapeDualPageEnabled && isLandscape) ? 2 : 1
guard let viewControllers = pageVC.viewControllers,
viewControllers.count == expectedCount else {
return true
}
let childViewControllers = viewControllers.compactMap { $0 as? RDEpubReaderPageChildViewController }
guard childViewControllers.count == expectedCount else {
return true
}
if expectedCount == 1 {
return childViewControllers.first?.pageNum != currentPage
}
let expectedPair = dualPagePair(for: currentPage)
let expectedRightPage = expectedPair.right
?? (isFullScreenPage(expectedPair.left) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum)
return childViewControllers[0].pageNum != expectedPair.left
|| childViewControllers[1].pageNum != expectedRightPage
}
private func patchPageViewControllerFault() {
guard currentPage >= 0 else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pagingController.resetPendingState()
self.willPreviousTransitionToViewController = nil
self.willNextTransitionToViewController = nil
self.willTransitionToViewController = nil
self.invalidatePageCaches()
self.rebuildPageViewController()
self.transitionToPage(pageNum: self.currentPage, animated: false)
}
}
private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool {
pagingController.shouldQueuePageTransition(request, currentDisplayType: currentDisplayType)
}
func finishPageCurlTransition(repairFaultIfNeeded: Bool = true) {
willPreviousTransitionToViewController = nil
willNextTransitionToViewController = nil
willTransitionToViewController = nil
if repairFaultIfNeeded, detectPageViewControllerFault(pageViewController) {
patchPageViewControllerFault()
return
}
if let pending = pagingController.finishPageCurlTransition() {
transitionToPage(pageNum: pending.pageNum, animated: pending.animated)
}
updatePagingInteractionSuppression()
}
private var preloadEnvironment: RDEpubReaderPreloadController.Environment {
RDEpubReaderPreloadController.Environment(
displayType: currentDisplayType,
isLandscape: isLandscape,
pagesPerScreen: pagesPerScreen,
boundsSize: bounds.size,
landscapeDualPageEnabled: landscapeDualPageEnabled,
coverPageIndex: coverPageIndex,
totalPages: numberOfPages(),
spreadResolver: spreadResolver
)
}
private func invalidatePageCaches() {
preloadController.invalidate(environment: preloadEnvironment)
}
func pageViewForDisplay(pageNum: Int) -> UIView {
preloadController.pageViewForDisplay(
pageNum: pageNum,
environment: preloadEnvironment,
contentViewProvider: pageContentViewForPreload(pageNum:reusableView:)
)
}
func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
preloadController.prime(
around: pageNum,
preferredForward: preferredForward,
parentView: self,
environment: preloadEnvironment,
contentViewProvider: pageContentViewForPreload(pageNum:reusableView:)
)
}
private func pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? {
contentViewForPage(pageNum, reusableView: reusableView)
}
public override func didMoveToSuperview() {
super.didMoveToSuperview()
if superview != nil {
makeUI()
}
}
private func attachPageViewControllerIfNeeded() {
guard let parentViewController = self.ss_superViewController else { return }
var didAddToParent = false
if pageViewController.parent !== parentViewController {
if pageViewController.parent != nil {
pageViewController.willMove(toParent: nil)
pageViewController.view.removeFromSuperview()
pageViewController.removeFromParent()
}
parentViewController.addChild(pageViewController)
didAddToParent = true
}
pageViewController.view.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
pageViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
if pageViewController.view.superview !== self {
insertSubview(pageViewController.view, at: 0)
}
if didAddToParent {
pageViewController.didMove(toParent: parentViewController)
}
if currentDisplayType == .pageCurl, !isTransitioning, pendingTransitionRequest != nil {
finishPageCurlTransition(repairFaultIfNeeded: false)
}
}
private func detachPageViewControllerIfNeeded() {
if pageViewController.parent != nil {
pageViewController.willMove(toParent: nil)
}
pageViewController.view.removeFromSuperview()
if pageViewController.parent != nil {
pageViewController.removeFromParent()
}
}
private func makeUI() {
guard !didBuildUI else {
if currentDisplayType == .pageCurl {
attachPageViewControllerIfNeeded()
}
return
}
didBuildUI = true
collectionView.dataSource = self
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: NSStringFromClass(UICollectionViewCell.self))
collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
if currentDisplayType == .pageCurl {
attachPageViewControllerIfNeeded()
}
preloadController.ensureHostView(in: self)
preloadController.initializeSignature(preloadEnvironment)
addGestureRecognizer(tapGestureRecognizer)
tapGestureRecognizer.cancelsTouchesInView = false
}
@objc private func tapAction(tap: UITapGestureRecognizer) {
let point = tap.location(in: tap.view)
let hitView = hitTest(point, with: nil)
RDEpubReaderTapDebug.log(
"ReaderView.tapAction",
"received point=\(RDEpubReaderTapDebug.describe(point)) hitView=\(RDEpubReaderTapDebug.describe(hitView)) display=\(currentDisplayType) currentPage=\(currentPage) toolVisible=\(isShowToolView) transitioning=\(isPageCurlTransitioning)"
)
if containsTextContentView(in: hitView) {
RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because hitView is inside text content view")
return
}
if shouldSuppressChromeToggle(for: hitView, point: point) {
RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because chrome toggle is suppressed")
return
}
handleResolvedTap(at: point, hitView: hitView, in: tap.view)
}
private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool {
if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
RDEpubReaderTapDebug.log(
"ReaderView.suppressChromeToggle",
"suppressed by active selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)"
)
return true
}
var currentView = hitView
while let view = currentView {
if let textContentView = view as? RDEPUBTextContentView {
let localPoint = convert(point, to: textContentView)
let shouldSuppress = textContentView.shouldSuppressReaderTap(at: localPoint)
if shouldSuppress {
RDEpubReaderTapDebug.log(
"ReaderView.suppressChromeToggle",
"suppressed by text content view=\(RDEpubReaderTapDebug.describe(textContentView)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))"
)
}
return shouldSuppress
}
currentView = view.superview
}
return false
}
func registerSelectionGestureDependenciesIfNeeded(for contentView: UIView) {
guard let textContentView = contentView as? RDEPUBTextContentView else { return }
let longPressGesture = textContentView.selectionLongPressGestureRecognizer
if registeredSelectionLongPressRecognizers.allObjects.contains(where: { $0 === longPressGesture }) {
return
}
tapGestureRecognizer.require(toFail: longPressGesture)
registeredSelectionLongPressRecognizers.add(longPressGesture)
}
func updateSelectionTapSuppression(for contentView: UIView, isSuppressed: Bool) {
if isSuppressed {
if selectionTapSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false {
selectionTapSuppressedContentViews.add(contentView)
}
} else {
selectionTapSuppressedContentViews.remove(contentView)
}
RDEpubReaderTapDebug.log(
"ReaderView.selectionTapSuppression",
"contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionTapSuppressedContentViews.allObjects.count)"
)
}
func updateSelectionPagingSuppression(for contentView: UIView, isSuppressed: Bool) {
if isSuppressed {
if selectionPagingSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false {
selectionPagingSuppressedContentViews.add(contentView)
}
} else {
selectionPagingSuppressedContentViews.remove(contentView)
}
RDEpubReaderTapDebug.log(
"ReaderView.selectionPagingSuppression",
"contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionPagingSuppressedContentViews.allObjects.count)"
)
updatePagingInteractionSuppression()
}
func handleContentTap(at point: CGPoint, in sourceView: UIView) {
let localPoint = sourceView.convert(point, to: self)
RDEpubReaderTapDebug.log(
"ReaderView.handleContentTap",
"forwarded from sourceView=\(RDEpubReaderTapDebug.describe(sourceView)) sourcePoint=\(RDEpubReaderTapDebug.describe(point)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))"
)
handleResolvedTap(at: localPoint, hitView: nil, in: self)
}
private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) {
if isShowToolView {
if let top = topToolView, isHitView(hitView, inside: top, point: point) {
RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits top tool view")
return
}
if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) {
RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits bottom tool view")
return
}
}
guard let viewFrame = tapView?.frame else { return }
tapEvent = tapRegionHandler.resolveTapEvent(
point: point,
viewFrame: viewFrame,
isToolViewVisible: isShowToolView
)
RDEpubReaderTapDebug.log(
"ReaderView.handleResolvedTap",
"resolved tapEvent=\(tapEvent) point=\(RDEpubReaderTapDebug.describe(point)) viewFrame=\(RDEpubReaderTapDebug.describe(viewFrame))"
)
}
private func containsTextContentView(in hitView: UIView?) -> Bool {
var currentView = hitView
while let view = currentView {
if view is RDEPUBTextContentView {
return true
}
currentView = view.superview
}
return false
}
private func updatePagingInteractionSuppression() {
let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false
guard shouldSuppress != isPagingInteractionSuppressed else { return }
isPagingInteractionSuppressed = shouldSuppress
RDEpubReaderTapDebug.log(
"ReaderView.pagingSuppression",
"updated shouldSuppress=\(shouldSuppress) activeContentViews=\(activePagingSuppressionContentViews().count)"
)
setPagingInteractionEnabled(!shouldSuppress)
}
private func activePagingSuppressionContentViews() -> [UIView] {
selectionPagingSuppressedContentViews.allObjects.filter { contentView in
guard contentView.window != nil,
contentView.isDescendant(of: self),
contentView.bounds.isEmpty == false else {
return false
}
let frameInReader = contentView.convert(contentView.bounds, to: self)
return frameInReader.intersects(bounds)
}
}
private func setPagingInteractionEnabled(_ isEnabled: Bool) {
collectionView.isScrollEnabled = isEnabled
pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled }
}
public func switchReaderDisplayType(_ displayType: RDEpubReaderView.DisplayType) {
let previousDisplayType = currentDisplayType
self.currentDisplayType = displayType
if currentPage == -1 {
currentPage = 0
}
if previousDisplayType != displayType {
invalidatePageCaches()
}
layout.isLandscapeDualPage = landscapeDualPageEnabled && isLandscape
layout.coverPageIndex = coverPageIndex
switch displayType {
case .pageCurl:
self.collectionView.removeFromSuperview()
self.collectionView.transform = .identity
attachPageViewControllerIfNeeded()
rebuildPageViewController()
transitionToPage(pageNum: currentPage)
primePageCache(around: currentPage, preferredForward: predictedPageDirection)
default:
detachPageViewControllerIfNeeded()
if pageDirection == .rightToLeft && displayType != .verticalScroll {
collectionView.transform = CGAffineTransform(scaleX: -1, y: 1)
} else {
collectionView.transform = .identity
}
collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
insertSubview(self.collectionView, at: 0)
layout.displayType = displayType
transitionToPage(pageNum: currentPage)
primePageCache(around: currentPage, preferredForward: predictedPageDirection)
}
}
public func transitionToPage(pageNum: Int, animated: Bool = false) {
guard let safePageNum = clampedPageNumber(pageNum) else { return }
switch currentDisplayType {
case .pageCurl:
if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) {
RDEpubReaderTapDebug.log(
"ReaderView.transitionToPage",
"skip same page page=\(safePageNum) animated=\(animated)"
)
return
}
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
if shouldQueuePageTransition(request) {
return
}
let isDualPage = landscapeDualPageEnabled && isLandscape
let direction: UIPageViewController.NavigationDirection
if pageDirection == .rightToLeft {
direction = safePageNum > currentPage ? .reverse : .forward
} else {
direction = safePageNum > currentPage ? .forward : .reverse
}
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
attachPageViewControllerIfNeeded()
if isDualPage {
let pair = dualPagePair(for: safePageNum)
let leftContent = pageViewForDisplay(pageNum: pair.left)
let leftVC = RDEpubReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
isTransitioning = animated
if let rightPage = pair.right {
let rightContent = pageViewForDisplay(pageNum: rightPage)
let rightVC = RDEpubReaderPageChildViewController(contentView: rightContent, pageNum: rightPage)
pageViewController.setViewControllers([leftVC, rightVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
guard let self else { return }
self.finishPageCurlTransition()
}
} else {
let blankNum = isFullScreenPage(pair.left) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum
let emptyVC = RDEpubReaderPageChildViewController(contentView: UIView(), pageNum: blankNum)
pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
guard let self else { return }
self.finishPageCurlTransition()
}
}
currentPage = pair.left
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
} else {
let contentView = pageViewForDisplay(pageNum: safePageNum)
let vc = RDEpubReaderPageChildViewController(contentView: contentView, pageNum: safePageNum)
isTransitioning = animated
pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
guard let self else { return }
self.finishPageCurlTransition()
}
currentPage = safePageNum
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
}
default:
collectionView.reloadData()
collectionView.layoutIfNeeded()
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
currentPage = safePageNum
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
}
}
public func reloadData() {
invalidatePageCaches()
switchReaderDisplayType(currentDisplayType)
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}
public func reloadPageCountOnly() {
if currentDisplayType == .pageCurl {
let totalPages = numberOfPages()
if !isPageCurlTransitioning, currentPage >= totalPages, totalPages > 0 {
transitionToPage(pageNum: totalPages - 1, animated: false)
}
} else {
collectionView.reloadData()
}
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension RDEpubReaderView: RDEpubReaderPageNavigating {
public func reloadPages() {
reloadData()
}
public func transition(to page: Int, animated: Bool) {
transitionToPage(pageNum: page, animated: animated)
}
}
extension RDEpubReaderView: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard gestureRecognizer === tapGestureRecognizer else { return true }
if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
RDEpubReaderTapDebug.log(
"ReaderView.gestureShouldReceive",
"return false because selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)"
)
return false
}
let point = touch.location(in: self)
if containsTextContentView(in: touch.view) {
RDEpubReaderTapDebug.log(
"ReaderView.gestureShouldReceive",
"return false because touch.view is inside text content view point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))"
)
return false
}
if let topToolView, isHitView(touch.view, inside: topToolView, point: point) {
RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit topToolView")
return false
}
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit bottomToolView")
return false
}
if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit searchBarView")
return false
}
RDEpubReaderTapDebug.log(
"ReaderView.gestureShouldReceive",
"return true point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))"
)
return true
}
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === tapGestureRecognizer
}
}
private var cellViewKey: Int8 = 0
extension UIView {
var ss_superViewController: UIViewController? {
var next = self.next
while next != nil {
if next is UIViewController {
return next as? UIViewController
} else {
next = next!.next
}
}
return nil
}
}