feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
@@ -1,58 +1,19 @@
|
||||
//
|
||||
// RDReaderView.swift
|
||||
// RDReaderDemo
|
||||
//
|
||||
// Created by yangsq on 2021/8/6.
|
||||
//
|
||||
// 文件职责:核心翻页容器视图,RDReaderView 模块的入口和中枢。
|
||||
// 该文件定义了阅读器的核心 UIView 子类 RDReaderView,负责:
|
||||
// 1. 管理三种翻页模式(仿真翻页、水平滚动、垂直滚动)
|
||||
// 2. 实现手势分区逻辑(左1/3上一页、中1/3工具栏、右1/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/3区域:上一页(RTL 时为下一页)
|
||||
|
||||
case left
|
||||
/// 中1/3区域:切换工具栏显示/隐藏
|
||||
|
||||
case center
|
||||
/// 右1/3区域:下一页(RTL 时为上一页)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// 每屏显示的页数
|
||||
/// 未启用双页、垂直滚动模式或竖屏时返回1,横屏双页模式返回2
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user