import UIKit enum RDEpubReaderTapDebug { private static let enabledArguments: Set = [ "--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 { /// The readable-content gutter between two landscape pages. Each page /// contributes half of this value from its inner content inset. public static let landscapeDualPageGap: CGFloat = 10 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 currentPage != oldValue { updatePageContentVisibility() } 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 ) } /// Whether a page occupies the leading (left in LTR) side of a landscape /// spread. Cover pages remain full-screen and are treated as leading. public func isLeadingPage(_ pageNum: Int) -> Bool { guard pagesPerScreen > 1, !isFullScreenPage(pageNum) else { return true } return dualPagePair(for: pageNum).left == pageNum } 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.weakObjects() private let selectionTapSuppressedContentViews = NSHashTable.weakObjects() private let selectionPagingSuppressedContentViews = NSHashTable.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 // Cached page views are configured with orientation-dependent content // insets and Web render requests. Reusing a portrait-preloaded page in // the rebuilt landscape page controller leaves the portrait safe-area // inset on all four edges (and keeps fixed-layout Web content under the // landscape sensor area). Drop them before requesting the new spread. invalidatePageCaches() 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 { let pageView = preloadController.pageViewForDisplay( pageNum: pageNum, environment: preloadEnvironment, contentViewProvider: contentViewForPage(_:reusableView:) ) // A data-source request is only a candidate. UIPageViewController may // ask for duplicate instances of the current page; activation happens // after the winning controller is actually installed. (pageView as? RDEpubReaderPageVisibilityHandling)? .readerPageVisibilityDidChange(isVisible: false) return pageView } 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? { let allowsPreloading = resolvedPageProvider? .readerView?(self, shouldPreloadPageAt: pageNum) ?? true guard allowsPreloading else { return nil } return contentViewForPage(pageNum, reusableView: reusableView) } func isReaderPageVisible(_ pageNum: Int) -> Bool { guard currentPage >= 0 else { return pageNum == 0 } if landscapeDualPageEnabled && isLandscape { let pair = dualPagePair(for: currentPage) return pageNum == pair.left || pageNum == pair.right } return pageNum == currentPage } func updatePageContentVisibility() { var visiblePageNumbers = Set() if currentPage >= 0 { visiblePageNumbers.insert(currentPage) if landscapeDualPageEnabled && isLandscape, let rightPage = dualPagePair(for: currentPage).right { visiblePageNumbers.insert(rightPage) } } let pageControllers: [RDEpubReaderPageChildViewController] let visibleCellContents: [(pageNumber: Int, contentView: UIView)] if currentDisplayType == .pageCurl { pageControllers = (pageViewController.viewControllers ?? []).compactMap { $0 as? RDEpubReaderPageChildViewController } visibleCellContents = [] } else { pageControllers = [] visibleCellContents = collectionView.indexPathsForVisibleItems.compactMap { indexPath in guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell, let contentView = cell.containerView else { return nil } return (indexPath.item, contentView) } } for pageController in pageControllers where !visiblePageNumbers.contains(pageController.pageNum) { guard let contentView = pageController.contentView else { continue } (contentView as? RDEpubReaderPageVisibilityHandling)? .readerPageVisibilityDidChange(isVisible: false) } for entry in visibleCellContents where !visiblePageNumbers.contains(entry.pageNumber) { (entry.contentView as? RDEpubReaderPageVisibilityHandling)? .readerPageVisibilityDidChange(isVisible: false) } // Suspend every cached non-winner before the attached winner below is // activated. Object identity matters because a page number can have // multiple UIPageViewController candidates. let visibleViewIdentifiers = Set( pageControllers.compactMap { pageController -> ObjectIdentifier? in guard visiblePageNumbers.contains(pageController.pageNum), let contentView = pageController.contentView else { return nil } return ObjectIdentifier(contentView) } + visibleCellContents.compactMap { entry -> ObjectIdentifier? in guard visiblePageNumbers.contains(entry.pageNumber) else { return nil } return ObjectIdentifier(entry.contentView) } ) preloadController.updatePageVisibility(visibleViewIdentifiers: visibleViewIdentifiers) for pageController in pageControllers where visiblePageNumbers.contains(pageController.pageNum) { guard let contentView = pageController.contentView else { continue } (contentView as? RDEpubReaderPageVisibilityHandling)? .readerPageVisibilityDidChange(isVisible: true) } for entry in visibleCellContents where visiblePageNumbers.contains(entry.pageNumber) { (entry.contentView as? RDEpubReaderPageVisibilityHandling)? .readerPageVisibilityDidChange(isVisible: true) } } 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() { for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] { guard let contentView = childController.contentView else { continue } (contentView as? RDEpubReaderPageVisibilityHandling)? .readerPageVisibilityDidChange(isVisible: false) } 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() // Programmatic setViewControllers transitions do not call the // UIPageViewControllerDelegate didFinishAnimating callback. Stop // the outgoing controllers explicitly before replacing them, even // when a duplicate page instance has fallen out of our cache map. for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] { guard let contentView = childController.contentView else { continue } (contentView as? RDEpubReaderPageVisibilityHandling)? .readerPageVisibilityDidChange(isVisible: false) } 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() } } let pageChanged = currentPage != pair.left currentPage = pair.left if !pageChanged { updatePageContentVisibility() } 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() } let pageChanged = currentPage != safePageNum currentPage = safePageNum if !pageChanged { updatePageContentVisibility() } primePageCache(around: safePageNum, preferredForward: predictedPageDirection) } default: collectionView.reloadData() collectionView.layoutIfNeeded() predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated) let pageChanged = currentPage != safePageNum currentPage = safePageNum if !pageChanged { updatePageContentVisibility() } 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 } }