Files
ReadViewSDK/Sources/RDReaderView/ReaderView/RDReaderView.swift
T
shenleiandClaude Opus 4.7 0e7c0577e3 feat: add in-reader search and restructure documentation
- Add RDEPUBReaderSearchBarView with animated show/hide, keyword
  navigation, and match counting integrated into the reader controller
- Restructure docs: replace scattered design docs with consolidated
  BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md
- Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests
- Add scripts/run_ui_regression.sh and summarize_ui_results.py for
  automated UI test execution and reporting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:34:50 +08:00

819 lines
34 KiB
Swift

//
// 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
pageVC.dataSource = self
return pageVC
}()
/// 自定义流式布局,支持水平滚动、垂直滚动和水平覆盖滚动三种布局模式
lazy var layout: RDReaderFlowLayout = {
let layout = RDReaderFlowLayout(displayType: .horizontalScroll)
layout.dataSource = self
layout.delegate = self
return layout
}()
/// 滚动模式使用的 UICollectionView
/// 用于 horizontalScroll 和 verticalScroll 两种翻页模式
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 = 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:))) {
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
}
}
}
/// 翻到下一页
/// 在双页模式下使用 adjacentDualPage 计算目标页码,单页模式直接 +1
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)
}
}
/// 翻到上一页
/// 在双页模式下使用 adjacentDualPage 计算目标页码,单页模式直接 -1
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 = 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? {
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
}
/// 每屏显示的页数
/// 未启用双页、垂直滚动模式或竖屏时返回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,
landscapeDualPageEnabled: landscapeDualPageEnabled,
isLandscape: isLandscape,
coverPageIndex: coverPageIndex
)
}
/// 根据逻辑页码计算横屏双页模式下的配对信息
/// 封面页独占一屏,后续页面两两配对
/// - Parameter pageNum: 逻辑页码
/// - Returns: (左页码, 右页码(可选,nil表示右页为空白))
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
let totalPages = numberOfPages()
return spreadResolver.dualPagePair(
for: pageNum,
totalPages: totalPages,
coverPageIndex: coverPageIndex
)
}
/// 计算横屏双页下,某页往前/后翻一屏后的起始页码
/// - Parameters:
/// - pageNum: 当前页码
/// - forward: 是否向后翻(true=下一页,false=上一页)
/// - Returns: 目标页码,nil 表示到头了
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)
}
/// 上一次检测到的横竖屏状态,用于在 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 var pendingTransitionRequest: PageTransitionRequest? {
get { pagingController.pendingTransitionRequest }
set { pagingController.pendingTransitionRequest = newValue }
}
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
// 延迟到下一个 RunLoop 执行,避免在 layoutSubviews 中嵌套触发 reloadData/layoutIfNeeded
// 造成 collectionView 中间态尺寸不一致的问题
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.orientationChanged(isNowLandscape: nowLandscape)
}
} else if previousIsLandscape == nil {
previousIsLandscape = nowLandscape
layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape
}
}
/// 横竖屏切换处理
/// 根据当前翻页模式执行不同的重建策略:
/// - 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)
collectionView.setContentOffset(targetOffset, animated: false)
primePageCache(around: safePage, preferredForward: predictedPageDirection)
}
}
}
/// 创建带指定 spine 位置的 UIPageViewController
/// - Parameter isDualPage: 是否双页模式(横屏时书脊在中间:.mid)
/// - Returns: 配置好的 UIPageViewController 实例
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
}
/// 重建 UIPageViewController
/// 横竖屏切换时需要重建,因为 spineLocation 只能在初始化时设置
/// 先移除旧的 PageViewController,再创建新的并添加到父控制器
private func rebuildPageViewController() {
detachPageViewControllerIfNeeded()
let isDualPage = landscapeDualPageEnabled && isLandscape
let pageVC = createPageViewController(isDualPage: isDualPage)
pageViewController = pageVC
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
guard let viewControllers = pageVC.viewControllers,
viewControllers.count == expectedCount else {
return true
}
let childViewControllers = viewControllers.compactMap { $0 as? RDReaderPageChildViewController }
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) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum)
return childViewControllers[0].pageNum != expectedPair.left
|| childViewControllers[1].pageNum != expectedRightPage
}
/// 修复 UIPageViewController 异常状态
/// 清空缓存、重建 PageViewController 并重新定位到当前页
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)
}
}
/// pageCurl 动画过程中如果收到新的跳转请求,则排队等待当前动画结束。
private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool {
pagingController.shouldQueuePageTransition(request, currentDisplayType: currentDisplayType)
}
/// 收尾 pageCurl 转场,并尝试执行排队中的后续跳转。
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)
}
}
// MARK: - Preload / Forecast
private var preloadEnvironment: RDReaderPreloadController.Environment {
RDReaderPreloadController.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)
}
/// 视图被添加到父视图时调用,触发 UI 构建
public override func didMoveToSuperview() {
super.didMoveToSuperview()
if superview != nil {
makeUI()
}
}
/// 将 pageViewController 添加到父控制器和视图层级
/// 如果已在父控制器中则跳过,确保生命周期方法正确调用
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)
}
}
/// 从父控制器和视图层级中移除 pageViewController
/// 确保 willMove/didMove 生命周期方法正确调用
private func detachPageViewControllerIfNeeded() {
if pageViewController.parent != nil {
pageViewController.willMove(toParent: nil)
}
pageViewController.view.removeFromSuperview()
if pageViewController.parent != nil {
pageViewController.removeFromParent()
}
}
/// 构建 UI 界面
/// 首次调用时注册 cell、添加手势识别器、根据当前翻页模式添加对应视图
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)
// 不取消底层触摸事件,确保工具栏按钮(返回等)的 touchUpInside 能正常触发
tapGestureRecognizer.cancelsTouchesInView = false
}
/// 点击手势响应方法
/// 根据点击位置将屏幕三等分,判断点击区域并设置 tapEvent
@objc private func tapAction(tap: UITapGestureRecognizer) {
let point = tap.location(in: tap.view)
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 }
tapEvent = tapRegionHandler.resolveTapEvent(
point: point,
viewFrame: viewFrame,
isToolViewVisible: isShowToolView
)
}
/// 切换翻页模式(仿真/水平滚动/上下滚动)
/// 会重建底层视图(PageViewController 或 CollectionView),并恢复到当前页
/// - Parameter displayType: 目标翻页模式
public func switchReaderDisplayType(_ displayType: RDReaderView.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()
// 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
transitionToPage(pageNum: currentPage)
primePageCache(around: currentPage, preferredForward: predictedPageDirection)
}
}
/// 跳转到指定页码
/// 支持所有翻页模式:
/// - 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 {
case .pageCurl:
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
if shouldQueuePageTransition(request) {
return
}
let isDualPage = landscapeDualPageEnabled && isLandscape
// RTL 时动画方向需要反转
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 = RDReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
isTransitioning = animated
if let rightPage = pair.right {
let rightContent = pageViewForDisplay(pageNum: rightPage)
let rightVC = RDReaderPageChildViewController(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) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum
let emptyVC = RDReaderPageChildViewController(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 = RDReaderPageChildViewController(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() {
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")
}
}
extension RDReaderView: RDReaderPageNavigating {
public func reloadPages() {
reloadData()
}
public func transition(to page: Int, animated: Bool) {
transitionToPage(pageNum: page, animated: animated)
}
}
extension RDReaderView: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard gestureRecognizer === tapGestureRecognizer else { return true }
let point = touch.location(in: self)
if let topToolView, isHitView(touch.view, inside: topToolView, point: point) {
return false
}
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
return false
}
if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
return false
}
return true
}
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === tapGestureRecognizer
}
}
private var cellViewKey: Int8 = 0
/// UIView 扩展:通过响应链查找最近的父 UIViewController
extension UIView {
/// 沿响应链向上查找最近的 UIViewController
/// 用于在视图中获取其所在控制器的引用
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
}
}