feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
+146 -171
View File
@@ -1,58 +1,19 @@
//
// RDReaderView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/6.
//
// RDReaderView
// UIView RDReaderView
// 1. 仿
// 2. 1/31/31/3
// 3.
// 4. (LTR)(RTL)
// 5. DataSource/Delegate
// 6.
//
// RDReaderView
// EPUBCore EPUBTextRendering RDReaderView EPUBUI UI
//
import UIKit
///
/// RDReaderView
///
///
/// 使
/// 1. ``dataSource``
/// 2. ``delegate``
/// 3. ``switchReaderDisplayType(_:)``
/// 4. ``reloadData()``
///
///
/// - pageCurl / horizontalScroll / verticalScroll
/// -
/// - RTL
/// - //
/// -
public class RDReaderView: UIView {
///
///
enum TapEvent {
///
case none
/// 1/3RTL
case left
/// 1/3/
case center
/// 1/3RTL
case right
}
/// 仿使 UIPageViewController
/// pageCurl 仿
lazy var pageViewController: UIPageViewController = {
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
pageVC.delegate = self
@@ -60,7 +21,6 @@ public class RDReaderView: UIView {
return pageVC
}()
///
lazy var layout: RDReaderFlowLayout = {
let layout = RDReaderFlowLayout(displayType: .horizontalScroll)
layout.dataSource = self
@@ -68,8 +28,6 @@ public class RDReaderView: UIView {
return layout
}()
/// 使 UICollectionView
/// horizontalScroll verticalScroll
lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
@@ -77,14 +35,15 @@ public class RDReaderView: UIView {
collectionView.accessibilityIdentifier = "epub.reader.paging"
return collectionView
}()
private let spreadResolver = RDReaderSpreadResolver()
private let tapRegionHandler = RDReaderTapRegionHandler()
let preloadController = RDReaderPreloadController()
var pagingController = RDReaderPagingController()
///
/// delegate
/// -1
public var currentPage: Int = -1 {
didSet {
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDReaderDelegate.pageNum(readerView:pageNum:))) {
@@ -93,16 +52,12 @@ public class RDReaderView: UIView {
}
}
///
///
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
@@ -123,8 +78,6 @@ public class RDReaderView: UIView {
}
}
///
/// 使 adjacentDualPage +1
private func goNextPage() {
let totalPages = numberOfPages()
if let target = spreadResolver.nextPage(
@@ -138,8 +91,6 @@ public class RDReaderView: UIView {
}
}
///
/// 使 adjacentDualPage -1
private func goPreviousPage() {
let totalPages = numberOfPages()
if let target = spreadResolver.nextPage(
@@ -154,30 +105,29 @@ public class RDReaderView: UIView {
}
private let legacyDataSourceAdapter = RDReaderLegacyDataSourceAdapter()
///
public weak var dataSource: RDReaderDataSource? {
didSet {
legacyDataSourceAdapter.dataSource = dataSource
}
}
/// ``dataSource``
public weak var pageProvider: RDReaderPageProvider?
///
public weak var delegate: RDReaderDelegate? = nil
/// 仿
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
/// / 0.3
public var toolViewAnimationDuration: TimeInterval = 0.3
///
var onToolViewVisibilityChanged: ((Bool) -> Void)?
/// tapCenter
var searchBarView: UIView?
///
public var landscapeDualPageEnabled: Bool = false
/// /
public var pageDirection: RDReaderView.PageDirection = .leftToRight
///
/// nil 0+1, 2+3, 4+5...
public var coverPageIndex: Int? = nil
private var resolvedPageProvider: RDReaderPageProvider? {
@@ -213,27 +163,20 @@ public class RDReaderView: UIView {
}
}
///
private var hasCoverPage: Bool {
return coverPageIndex != nil
}
/// >
var isLandscape: Bool {
return bounds.width > bounds.height
}
///
/// 12
public var pagesPerScreen: Int {
if !landscapeDualPageEnabled { return 1 }
if currentDisplayType == .verticalScroll { return 1 }
return isLandscape ? 2 : 1
}
///
/// - Parameter pageNum:
/// - Returns:
public func isFullScreenPage(_ pageNum: Int) -> Bool {
spreadResolver.isFullScreenPage(
pageNum,
@@ -243,10 +186,6 @@ public class RDReaderView: UIView {
)
}
///
///
/// - Parameter pageNum:
/// - Returns: (, (nil))
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
let totalPages = numberOfPages()
return spreadResolver.dualPagePair(
@@ -256,11 +195,6 @@ public class RDReaderView: UIView {
)
}
/// /
/// - Parameters:
/// - pageNum:
/// - forward: true=false=
/// - Returns: nil
private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? {
let totalPages = numberOfPages()
return spreadResolver.adjacentDualPage(
@@ -271,60 +205,63 @@ public class RDReaderView: UIView {
)
}
///
private func clampedPageNumber(_ pageNum: Int) -> Int? {
let totalPages = numberOfPages()
guard totalPages > 0 else { return nil }
return min(max(pageNum, 0), totalPages - 1)
}
/// layoutSubviews
private var previousIsLandscape: Bool?
/// key
var contentViews = [String : UIView.Type]()
/// pageCurl
var willPreviousTransitionToViewController: UIViewController? = nil
/// pageCurl
var willNextTransitionToViewController: UIViewController? = nil
/// pageCurl
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 }
}
/// UI
private var didBuildUI: Bool {
get { pagingController.didBuildUI }
set { pagingController.didBuildUI = newValue }
}
/// true=false=
var predictedPageDirection: Bool?
/// 1
public var preloadRadius: Int {
get { preloadController.radius }
set { preloadController.radius = newValue }
}
/// pageCurl
static let blankPageNum = Int.max
/// pageCurl
static let blankEndPageNum = Int.max - 1
/// pageCurl
private typealias PageTransitionRequest = RDReaderPagingController.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 }
@@ -336,8 +273,6 @@ public class RDReaderView: UIView {
isAccessibilityElement = false
}
///
///
public override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
@@ -346,8 +281,7 @@ public class RDReaderView: UIView {
let nowLandscape = isLandscape
if let prev = previousIsLandscape, prev != nowLandscape {
previousIsLandscape = nowLandscape
// RunLoop layoutSubviews reloadData/layoutIfNeeded
// collectionView
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.orientationChanged(isNowLandscape: nowLandscape)
@@ -358,33 +292,28 @@ public class RDReaderView: UIView {
}
}
///
///
/// - pageCurl UIPageViewController spineLocation
/// -
/// - Parameter isNowLandscape:
private func orientationChanged(isNowLandscape: Bool) {
let savedPage = max(0, currentPage)
// 1.
delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape)
// 2.
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
layout.coverPageIndex = coverPageIndex
switch currentDisplayType {
case .pageCurl:
// 仿 spineLocation PageViewController
rebuildPageViewController()
transitionToPage(pageNum: savedPage)
primePageCache(around: savedPage, preferredForward: predictedPageDirection)
default:
//
UIView.performWithoutAnimation {
// 使
collectionView.collectionViewLayout.invalidateLayout()
collectionView.reloadData()
collectionView.layoutIfNeeded()
// 3.
let totalPages = numberOfPages()
let safePage = min(savedPage, max(0, totalPages - 1))
let targetOffset = layout.currentContentOffset(count: safePage)
@@ -394,9 +323,6 @@ public class RDReaderView: UIView {
}
}
/// spine UIPageViewController
/// - Parameter isDualPage: .mid
/// - Returns: UIPageViewController
private func createPageViewController(isDualPage: Bool) -> UIPageViewController {
let options: [UIPageViewController.OptionsKey: Any]?
if isDualPage {
@@ -411,9 +337,6 @@ public class RDReaderView: UIView {
return pageVC
}
/// UIPageViewController
/// spineLocation
/// PageViewController
private func rebuildPageViewController() {
detachPageViewControllerIfNeeded()
@@ -423,12 +346,6 @@ public class RDReaderView: UIView {
attachPageViewControllerIfNeeded()
}
// MARK: - UIPageViewController Fault Detection
/// UIPageViewController
///
/// - Parameter pageVC: UIPageViewController
/// - Returns: true
private func detectPageViewControllerFault(_ pageVC: UIPageViewController) -> Bool {
guard currentDisplayType == .pageCurl else { return false }
let expectedCount = (landscapeDualPageEnabled && isLandscape) ? 2 : 1
@@ -453,8 +370,6 @@ public class RDReaderView: UIView {
|| childViewControllers[1].pageNum != expectedRightPage
}
/// UIPageViewController
/// PageViewController
private func patchPageViewControllerFault() {
guard currentPage >= 0 else { return }
DispatchQueue.main.async { [weak self] in
@@ -469,12 +384,10 @@ public class RDReaderView: UIView {
}
}
/// pageCurl
private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool {
pagingController.shouldQueuePageTransition(request, currentDisplayType: currentDisplayType)
}
/// pageCurl
func finishPageCurlTransition(repairFaultIfNeeded: Bool = true) {
willPreviousTransitionToViewController = nil
willNextTransitionToViewController = nil
@@ -490,8 +403,6 @@ public class RDReaderView: UIView {
}
}
// MARK: - Preload / Forecast
private var preloadEnvironment: RDReaderPreloadController.Environment {
RDReaderPreloadController.Environment(
displayType: currentDisplayType,
@@ -531,7 +442,6 @@ public class RDReaderView: UIView {
contentViewForPage(pageNum, reusableView: reusableView)
}
/// UI
public override func didMoveToSuperview() {
super.didMoveToSuperview()
@@ -540,8 +450,6 @@ public class RDReaderView: UIView {
}
}
/// pageViewController
///
private func attachPageViewControllerIfNeeded() {
guard let parentViewController = self.ss_superViewController else { return }
@@ -571,8 +479,6 @@ public class RDReaderView: UIView {
}
}
/// pageViewController
/// willMove/didMove
private func detachPageViewControllerIfNeeded() {
if pageViewController.parent != nil {
pageViewController.willMove(toParent: nil)
@@ -583,8 +489,6 @@ public class RDReaderView: UIView {
}
}
/// UI
/// cell
private func makeUI() {
guard !didBuildUI else {
if currentDisplayType == .pageCurl {
@@ -606,21 +510,80 @@ public class RDReaderView: UIView {
preloadController.initializeSignature(preloadEnvironment)
addGestureRecognizer(tapGestureRecognizer)
// touchUpInside
tapGestureRecognizer.cancelsTouchesInView = false
}
///
/// tapEvent
@objc private func tapAction(tap: UITapGestureRecognizer) {
let point = tap.location(in: tap.view)
let hitView = hitTest(point, with: nil)
if containsTextContentView(in: hitView) {
return
}
if shouldSuppressChromeToggle(for: hitView, point: point) {
return
}
handleResolvedTap(at: point, hitView: hitView, in: tap.view)
}
private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool {
if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
return true
}
var currentView = hitView
while let view = currentView {
if let textContentView = view as? RDEPUBTextContentView {
let localPoint = convert(point, to: textContentView)
return textContentView.shouldSuppressReaderTap(at: localPoint)
}
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)
}
}
func updateSelectionPagingSuppression(for contentView: UIView, isSuppressed: Bool) {
if isSuppressed {
if selectionPagingSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false {
selectionPagingSuppressedContentViews.add(contentView)
}
} else {
selectionPagingSuppressedContentViews.remove(contentView)
}
updatePagingInteractionSuppression()
}
func handleContentTap(at point: CGPoint, in sourceView: UIView) {
let localPoint = sourceView.convert(point, to: self)
handleResolvedTap(at: localPoint, hitView: nil, in: self)
}
private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) {
if isShowToolView {
let hitView = hitTest(point, with: nil)
if let top = topToolView, isHitView(hitView, inside: top, point: point) { return }
if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { return }
}
guard let viewFrame = tap.view?.frame else { return }
guard let viewFrame = tapView?.frame else { return }
tapEvent = tapRegionHandler.resolveTapEvent(
point: point,
viewFrame: viewFrame,
@@ -628,9 +591,29 @@ public class RDReaderView: UIView {
)
}
/// 仿//
/// PageViewController CollectionView
/// - Parameter displayType:
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 = selectionPagingSuppressedContentViews.allObjects.isEmpty == false
guard shouldSuppress != isPagingInteractionSuppressed else { return }
isPagingInteractionSuppressed = shouldSuppress
setPagingInteractionEnabled(!shouldSuppress)
}
private func setPagingInteractionEnabled(_ isEnabled: Bool) {
collectionView.isScrollEnabled = isEnabled
pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled }
}
public func switchReaderDisplayType(_ displayType: RDReaderView.DisplayType) {
let previousDisplayType = currentDisplayType
self.currentDisplayType = displayType
@@ -640,7 +623,7 @@ public class RDReaderView: UIView {
if previousDisplayType != displayType {
invalidatePageCaches()
}
//
layout.isLandscapeDualPage = landscapeDualPageEnabled && isLandscape
layout.coverPageIndex = coverPageIndex
switch displayType {
@@ -653,13 +636,13 @@ public class RDReaderView: UIView {
primePageCache(around: currentPage, preferredForward: predictedPageDirection)
default:
detachPageViewControllerIfNeeded()
// RTL collectionView
if pageDirection == .rightToLeft && displayType != .verticalScroll {
collectionView.transform = CGAffineTransform(scaleX: -1, y: 1)
} else {
collectionView.transform = .identity
}
// collectionView frame
collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
insertSubview(self.collectionView, at: 0)
layout.displayType = displayType
@@ -668,13 +651,6 @@ public class RDReaderView: UIView {
}
}
///
///
/// - pageCurl UIPageViewController.setViewControllers
/// - setContentOffset
/// - Parameters:
/// - pageNum: item
/// - animated:
public func transitionToPage(pageNum: Int, animated: Bool = false) {
guard let safePageNum = clampedPageNumber(pageNum) else { return }
switch currentDisplayType {
@@ -685,7 +661,7 @@ public class RDReaderView: UIView {
}
let isDualPage = landscapeDualPageEnabled && isLandscape
// RTL
let direction: UIPageViewController.NavigationDirection
if pageDirection == .rightToLeft {
direction = safePageNum > currentPage ? .reverse : .forward
@@ -707,7 +683,7 @@ public class RDReaderView: UIView {
self.finishPageCurlTransition()
}
} else {
//
let blankNum = isFullScreenPage(pair.left) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum
let emptyVC = RDReaderPageChildViewController(contentView: UIView(), pageNum: blankNum)
pageViewController.setViewControllers([leftVC, emptyVC], direction: animated ? direction : .forward, animated: animated) { [weak self] _ in
@@ -738,22 +714,18 @@ public class RDReaderView: UIView {
}
}
///
///
public func reloadData() {
switchReaderDisplayType(currentDisplayType)
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}
///
public func reloadPageCountOnly() {
collectionView.reloadData()
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@@ -774,7 +746,14 @@ extension RDReaderView: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard gestureRecognizer === tapGestureRecognizer else { return true }
if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
return false
}
let point = touch.location(in: self)
if containsTextContentView(in: touch.view) {
return false
}
if let topToolView, isHitView(touch.view, inside: topToolView, point: point) {
return false
}
@@ -795,14 +774,10 @@ extension RDReaderView: UIGestureRecognizerDelegate {
}
}
private var cellViewKey: Int8 = 0
/// UIView UIViewController
extension UIView {
/// 沿 UIViewController
///
var ss_superViewController: UIViewController? {
var next = self.next
while next != nil {