Files
ReadViewSDK/Sources/RDReaderView/ReaderView/RDReaderView.swift
T
shenandshen 54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00

1220 lines
53 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 提供页面数量、内容视图和工具栏。
/// 所有方法由 RDReaderView 在需要渲染页面时回调。
@objc public protocol RDReaderDataSource: NSObjectProtocol {
/// 返回阅读器的总页数
/// - Parameter readerView: 发起请求的阅读器视图
/// - Returns: 总页数
func pageCountOfReaderView(readerView: RDReaderView) -> Int
/// 返回指定页码的内容视图
/// - Parameters:
/// - readerView: 发起请求的阅读器视图
/// - pageNum: 页码(从0开始)
/// - containerView: 可复用的容器视图(非 nil 时可复用以减少创建开销)
/// - Returns: 该页的内容视图
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
/// 返回指定页码的唯一标识符,用于 UICollectionViewCell 复用
/// - Parameters:
/// - readerView: 发起请求的阅读器视图
/// - pageNum: 页码
/// - Returns: 页标识符字符串,nil 则使用默认 cell
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
/// 返回顶部工具栏视图(可选),点击屏幕中央时会显示/隐藏
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
/// 返回底部工具栏视图(可选),点击屏幕中央时会显示/隐藏
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
}
/// 阅读器代理协议
/// 上层控制器通过实现此协议,接收翻页和横竖屏切换事件
@objc public protocol RDReaderDelegate: NSObjectProtocol {
/// 翻页回调,当当前显示页码变化时触发
/// - Parameters:
/// - readerView: 发起回调的阅读器视图
/// - pageNum: 当前页码
func pageNum(readerView: RDReaderView, pageNum: Int)
/// 横竖屏切换时回调,可在此重新分页
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
}
extension RDReaderView {
/// 翻页模式枚举,决定 RDReaderView 使用哪种底层视图来展示内容
public enum DisplayType {
/// 仿真翻页:使用 UIPageViewController,模拟真实翻书效果
case pageCurl
/// 水平滚动:使用 UICollectionView + 自定义布局,每屏一项,水平分页
case horizontalScroll
/// 垂直滚动:使用 UICollectionView,全宽项目,垂直连续滚动
case verticalScroll
}
/// 翻页方向枚举
public enum PageDirection {
/// 从左往右翻页(默认,适用于中文/英文书籍)
case leftToRight
/// 从右往左翻页(适用于日文漫画等)
case rightToLeft
}
}
/// 核心翻页容器视图
/// 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 转场样式实现仿真翻书效果
private lazy var pageViewController: UIPageViewController = {
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
pageVC.delegate = self
pageVC.dataSource = self
return pageVC
}()
/// 自定义流式布局,支持水平滚动、垂直滚动和水平覆盖滚动三种布局模式
private lazy var layout: RDReaderFlowLayout = {
let layout = RDReaderFlowLayout(displayType: .horizontalScroll)
layout.dataSource = self
layout.delegate = self
return layout
}()
/// 滚动模式使用的 UICollectionView
/// 用于 horizontalScroll 和 verticalScroll 两种翻页模式
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.backgroundColor = UIColor.clear
return collectionView
}()
/// 当前显示的页码
/// 值变化时会通过 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:)))
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 = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
if pagesPerScreen > 1 {
if let target = adjacentDualPage(from: currentPage, forward: true) {
transitionToPage(pageNum: target, animated: true)
}
} else if currentPage + 1 < totalPages {
transitionToPage(pageNum: currentPage + 1, animated: true)
}
}
/// 翻到上一页
/// 在双页模式下使用 adjacentDualPage 计算目标页码,单页模式直接 -1
private func goPreviousPage() {
if pagesPerScreen > 1 {
if let target = adjacentDualPage(from: currentPage, forward: false) {
transitionToPage(pageNum: target, animated: true)
}
} else if currentPage - 1 >= 0 {
transitionToPage(pageNum: currentPage - 1, animated: true)
}
}
/// 数据源代理,提供页面数量、内容视图和工具栏
public weak var dataSource: RDReaderDataSource? = nil
/// 代理,接收翻页和横竖屏切换事件
public weak var delegate: RDReaderDelegate? = nil
/// 当前翻页模式,默认为仿真翻页
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
/// 工具栏显示/隐藏动画时长,默认 0.3 秒
public var toolViewAnimationDuration: TimeInterval = 0.3
/// 是否启用横屏双页显示
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 hasCoverPage: Bool {
return coverPageIndex != nil
}
/// 当前是否横屏(宽度 > 高度)
private 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 {
guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false }
return pageNum == coverIndex
}
/// 根据逻辑页码计算横屏双页模式下的配对信息
/// 封面页独占一屏,后续页面两两配对
/// - Parameter pageNum: 逻辑页码
/// - Returns: (左页码, 右页码(可选,nil表示右页为空白))
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
if let coverIndex = coverPageIndex {
if pageNum == coverIndex {
// 封面页独占一屏
return (coverIndex, nil)
}
// 封面之后的页面:偏移1后两两配对
// 例如封面是0页,则 1+2, 3+4, 5+6...
let adjustedIndex = pageNum - (coverIndex + 1) // 跳过封面后的偏移
let pairStart = coverIndex + 1 + (adjustedIndex / 2) * 2
let left = pairStart
let right = pairStart + 1 < totalPages ? pairStart + 1 : nil
return (left, right)
} else {
// 无封面页:正常两两配对 0+1, 2+3, 4+5...
let left = (pageNum / 2) * 2
let right = left + 1 < totalPages ? left + 1 : nil
return (left, right)
}
}
/// 计算横屏双页下,某页往前/后翻一屏后的起始页码
/// - Parameters:
/// - pageNum: 当前页码
/// - forward: 是否向后翻(true=下一页,false=上一页)
/// - Returns: 目标页码,nil 表示到头了
private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
let pair = dualPagePair(for: pageNum)
if forward {
let nextStart = (pair.right ?? pair.left) + 1
return nextStart < totalPages ? nextStart : nil
} else {
let prevEnd = pair.left - 1
guard prevEnd >= 0 else { return nil }
return dualPagePair(for: prevEnd).left
}
}
/// 上一次检测到的横竖屏状态,用于在 layoutSubviews 中检测方向变化
private var previousIsLandscape: Bool?
/// 已注册的内容视图类型字典,key 为重用标识符
private var contentViews = [String : UIView.Type]()
/// pageCurl 模式下,即将向前翻转到的子控制器
private var willPreviousTransitionToViewController: UIViewController? = nil
/// pageCurl 模式下,即将向后翻转到的子控制器
private var willNextTransitionToViewController: UIViewController? = nil
/// pageCurl 模式下,正在转场的目标控制器
private var willTransitionToViewController: UIViewController? = nil
/// 顶部工具栏视图
private var topToolView: UIView?
/// 底部工具栏视图
private var bottomToolView: UIView?
/// 工具栏是否正在显示
private var isShowToolView: Bool = false
/// 是否正在执行页面转场动画
private var isTransitioning: Bool = false
/// UI 是否已经构建完成(避免重复构建)
private var didBuildUI = false
/// 预加载宿主视图(隐藏不可见),用于存放预加载的页面内容
private let preloadHostView = UIView()
/// 预加载的页面视图缓存,key 为页码
private var preloadedPageViews: [Int: UIView] = [:]
/// pageCurl 模式下的页面视图缓存,key 为页码
private var pageCurlCachedViews: [Int: UIView] = [:]
/// 预测的翻页方向(true=向前,false=向后),用于优化预加载方向
private var predictedPageDirection: Bool?
/// 预加载半径:当前页前后各预加载几屏,默认 1 屏
public var preloadRadius: Int = 1
/// 缓存签名:用于检测布局环境是否变化,变化时需要清空缓存
private var cacheSignature: CacheSignature?
/// 用于 pageCurl 双页模式下封面页旁边的空白页标识
static let blankPageNum = Int.max
/// 用于 pageCurl 双页模式下末尾不成对页旁边的空白页标识
static let blankEndPageNum = Int.max - 1
/// 缓存签名结构体
/// 当这些参数中的任何一个发生变化时,缓存需要被清空并重建
private struct CacheSignature: Equatable {
/// 当前翻页模式
let displayType: DisplayType
/// 是否横屏
let isLandscape: Bool
/// 每屏页数
let pagesPerScreen: Int
/// 视图尺寸
let boundsSize: CGSize
}
override init(frame: CGRect) {
super.init(frame: frame)
}
/// 布局子视图时调用
/// 检测横竖屏方向变化,触发相应的布局更新和页面重建
public override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0 else { return }
preloadHostView.frame = bounds
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 = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
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.invalidatePageCaches()
self.rebuildPageViewController()
self.transitionToPage(pageNum: self.currentPage, animated: false)
}
}
// MARK: - Preload / Forecast
/// 确保预加载宿主视图已添加到视图层级
/// 该视图隐藏且不可交互,仅用于存放预加载的页面内容
private func ensurePreloadHostView() {
guard preloadHostView.superview == nil else { return }
preloadHostView.isHidden = true
preloadHostView.isUserInteractionEnabled = false
preloadHostView.clipsToBounds = true
preloadHostView.frame = bounds
preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
insertSubview(preloadHostView, at: 0)
}
/// 判断指定页码是否可以被缓存
/// - Parameter pageNum: 页码
/// - Returns: true 表示可以缓存(有效页码且在范围内)
private func shouldCachePage(_ pageNum: Int) -> Bool {
pageNum >= 0
&& pageNum != RDReaderView.blankPageNum
&& pageNum != RDReaderView.blankEndPageNum
&& pageNum < (dataSource?.pageCountOfReaderView(readerView: self) ?? 0)
}
/// 获取当前缓存签名,用于检测布局环境是否发生变化
private func currentCacheSignature() -> CacheSignature {
CacheSignature(
displayType: currentDisplayType,
isLandscape: isLandscape,
pagesPerScreen: pagesPerScreen,
boundsSize: bounds.size
)
}
/// 清空所有页面缓存并更新缓存签名
/// 在翻页模式切换、横竖屏切换时调用
private func invalidatePageCaches() {
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
pageCurlCachedViews.removeAll()
preloadedPageViews.removeAll()
cacheSignature = currentCacheSignature()
}
/// 检查缓存签名是否变化,变化则清空缓存
private func refreshCacheSignatureIfNeeded() {
let signature = currentCacheSignature()
if cacheSignature != signature {
invalidatePageCaches()
} else if cacheSignature == nil {
cacheSignature = signature
}
}
/// 计算指定页码所在"展开对"的起始页码
/// 单页模式返回自身,双页模式返回配对的左页码
private func spreadStart(for pageNum: Int) -> Int? {
guard shouldCachePage(pageNum) else { return nil }
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
guard isDualPage else { return pageNum }
return dualPagePair(for: pageNum).left
}
/// 获取指定页码所在"展开对"的所有页码
/// 单页模式返回自身,双页模式返回左右两页
private func spreadPageNumbers(startingAt pageNum: Int) -> Set<Int> {
guard shouldCachePage(pageNum) else { return [] }
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
guard isDualPage else { return [pageNum] }
let pair = dualPagePair(for: pageNum)
var pages: Set<Int> = [pair.left]
if let right = pair.right, shouldCachePage(right) {
pages.insert(right)
}
return pages
}
/// 计算相邻"展开对"的起始页码,用于预加载时计算前后相邻页
private func adjacentSpreadStart(from pageNum: Int, forward: Bool) -> Int? {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
guard totalPages > 0,
let spreadStart = spreadStart(for: pageNum) else {
return nil
}
let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll
guard isDualPage else {
let target = forward ? spreadStart + 1 : spreadStart - 1
return shouldCachePage(target) ? target : nil
}
if forward {
return adjacentDualPage(from: spreadStart, forward: true)
}
let pair = dualPagePair(for: spreadStart)
let prevEnd = pair.left - 1
guard prevEnd >= 0 else { return nil }
return dualPagePair(for: prevEnd).left
}
/// 获取锚定页码对应的可见页码集合
private func visiblePageNumbers(for anchorPage: Int) -> Set<Int> {
spreadPageNumbers(startingAt: anchorPage)
}
/// 获取用于显示的页面视图
/// 优先从缓存中获取,其次从预加载缓存中获取,最后新建
private func pageViewForDisplay(pageNum: Int) -> UIView {
if let cached = pageCurlCachedViews[pageNum] {
return cached
}
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
preloaded.removeFromSuperview()
pageCurlCachedViews[pageNum] = preloaded
return preloaded
}
let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil) ?? UIView()
pageCurlCachedViews[pageNum] = view
return view
}
/// 裁剪缓存,只保留指定页码集合中的页面
/// 不在保留集合中的页面会被从视图层级中移除
private func trimCachedPageViews(keeping pageNumbers: Set<Int>) {
pageCurlCachedViews = pageCurlCachedViews.filter { key, value in
let keep = pageNumbers.contains(key)
if !keep {
value.removeFromSuperview()
}
return keep
}
preloadedPageViews = preloadedPageViews.filter { key, value in
let keep = pageNumbers.contains(key)
if !keep {
value.removeFromSuperview()
}
return keep
}
}
/// 预测需要预加载的页码集合
/// 基于当前页码和 preloadRadius,向前和向后各预测 preloadRadius 屏,
/// 并根据 preferredForward 方向额外多预测一屏
private func forecastTargets(around pageNum: Int, preferredForward: Bool?) -> [Int] {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
guard totalPages > 0, shouldCachePage(pageNum) else { return [] }
var targets = Set<Int>()
var previousAnchor = pageNum
for _ in 0..<max(preloadRadius, 0) {
guard let prev = adjacentSpreadStart(from: previousAnchor, forward: false) else { break }
targets.formUnion(spreadPageNumbers(startingAt: prev))
previousAnchor = prev
}
var nextAnchor = pageNum
for _ in 0..<max(preloadRadius, 0) {
guard let next = adjacentSpreadStart(from: nextAnchor, forward: true) else { break }
targets.formUnion(spreadPageNumbers(startingAt: next))
nextAnchor = next
}
if let preferredForward,
let edgeAnchor = preferredForward ? adjacentSpreadStart(from: nextAnchor, forward: true)
: adjacentSpreadStart(from: previousAnchor, forward: false) {
targets.formUnion(spreadPageNumbers(startingAt: edgeAnchor))
}
return targets.sorted()
}
/// 预加载核心方法
/// 1. 检查缓存签名是否需要更新
/// 2. 预测需要预加载的页码
/// 3. 裁剪不在范围内的缓存
/// 4. 创建或复用预加载视图
private func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
refreshCacheSignatureIfNeeded()
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward)
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum))
trimCachedPageViews(keeping: keepSet)
guard !targets.isEmpty else { return }
ensurePreloadHostView()
preloadHostView.frame = bounds
for targetPage in targets {
let existing = preloadedPageViews[targetPage] ?? pageCurlCachedViews.removeValue(forKey: targetPage)
let contentView = dataSource?.pageContentView(readerView: self, pageNum: targetPage, containerView: existing) ?? existing ?? UIView()
preloadedPageViews[targetPage] = contentView
if contentView.superview !== preloadHostView {
contentView.removeFromSuperview()
preloadHostView.addSubview(contentView)
}
contentView.frame = preloadHostView.bounds
}
}
/// 视图被添加到父视图时调用,触发 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)
}
}
/// 从父控制器和视图层级中移除 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()
}
ensurePreloadHostView()
cacheSignature = currentCacheSignature()
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 }
}
let viewFrame = tap.view!.frame
let leftFrame = CGRect(x: 0, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
let centerFrame = CGRect(x: viewFrame.width / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
let rightFrame = CGRect(x: viewFrame.width * 2 / 3, y: 0, width: viewFrame.width / 3, height: viewFrame.height)
if leftFrame.contains(point) {
if isShowToolView {
tapEvent = .center
} else {
tapEvent = .left
}
}
if centerFrame.contains(point) {
tapEvent = .center
}
if rightFrame.contains(point) {
if isShowToolView {
tapEvent = .center
} else {
tapEvent = .right
}
}
}
/// 点击屏幕中央区域,切换工具栏的显示/隐藏
/// 显示时:工具栏从屏幕外滑入,禁用翻页交互
/// 隐藏时:工具栏滑出屏幕,恢复翻页交互
private func tapCenter() {
isShowToolView = !isShowToolView
if isShowToolView {
if let topToolView = topToolView {
addSubview(topToolView)
topToolView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
topToolView.leadingAnchor.constraint(equalTo: leadingAnchor),
topToolView.trailingAnchor.constraint(equalTo: trailingAnchor),
topToolView.topAnchor.constraint(equalTo: topAnchor)
])
layoutIfNeeded()
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
UIView.animate(withDuration: toolViewAnimationDuration) {
topToolView.transform = .identity
}
}
if let bottomToolView = bottomToolView {
addSubview(bottomToolView)
bottomToolView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
bottomToolView.leadingAnchor.constraint(equalTo: leadingAnchor),
bottomToolView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomToolView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
layoutIfNeeded()
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
UIView.animate(withDuration: toolViewAnimationDuration) {
bottomToolView.transform = .identity
}
}
collectionView.isUserInteractionEnabled = false
pageViewController.view.isUserInteractionEnabled = false
} else {
if let topToolView = topToolView {
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
}) { _ in
topToolView.removeFromSuperview()
}
}
if let bottomToolView = bottomToolView {
UIView.animate(withDuration: toolViewAnimationDuration, animations: {
bottomToolView.transform = CGAffineTransform(translationX: 0, y: bottomToolView.bounds.height)
}) { _ in
bottomToolView.removeFromSuperview()
}
}
collectionView.isUserInteractionEnabled = true
pageViewController.view.isUserInteractionEnabled = true
}
}
/// 判断点击命中的视图是否在指定工具栏内
private func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
if toolView.frame.contains(point) {
return true
}
guard let hitView else { return false }
return hitView === toolView || hitView.isDescendant(of: toolView)
}
/// 切换翻页模式(仿真/水平滚动/上下滚动)
/// 会重建底层视图(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) {
switch currentDisplayType {
case .pageCurl:
let isDualPage = landscapeDualPageEnabled && isLandscape
// RTL 时动画方向需要反转
let direction: UIPageViewController.NavigationDirection
if pageDirection == .rightToLeft {
direction = pageNum > currentPage ? .reverse : .forward
} else {
direction = pageNum > currentPage ? .forward : .reverse
}
if isDualPage {
let pair = dualPagePair(for: pageNum)
let leftContent = pageViewForDisplay(pageNum: pair.left)
let leftVC = RDReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
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, completion: nil)
} 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, completion: nil)
}
currentPage = pair.left
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
} else {
let contentView = pageViewForDisplay(pageNum: pageNum)
let vc = RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
pageViewController.setViewControllers([vc], direction: animated ? direction : .forward, animated: animated, completion: nil)
currentPage = pageNum
primePageCache(around: pageNum, preferredForward: predictedPageDirection)
}
default:
collectionView.reloadData()
collectionView.layoutIfNeeded()
collectionView.setContentOffset(layout.currentContentOffset(count: pageNum), animated: animated)
currentPage = pageNum
primePageCache(around: pageNum, preferredForward: predictedPageDirection)
}
}
/// 重新加载数据
/// 重新切换到当前翻页模式,并刷新工具栏
public func reloadData() {
switchReaderDisplayType(currentDisplayType)
topToolView = self.dataSource?.topToolView?(readerView: self)
bottomToolView = self.dataSource?.bottomToolView?(readerView: self)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// UIPageViewController 数据源和代理实现
/// 处理仿真翻页模式下的页面数据供给和翻页事件
extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
/// 创建指定页码的子控制器
/// 空白页返回空视图,正常页从缓存或数据源获取内容
private func makeSinglePageChildVC(for pageNum: Int) -> RDReaderPageChildViewController {
if pageNum == RDReaderView.blankPageNum || pageNum == RDReaderView.blankEndPageNum {
return RDReaderPageChildViewController(contentView: UIView(), pageNum: pageNum)
}
let contentView = pageViewForDisplay(pageNum: pageNum)
return RDReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
}
/// 计算某页的"下一页"页码
/// 考虑封面页独占和末尾空白页配对的情况
/// - Parameters:
/// - pageNum: 当前页码
/// - isDualPage: 是否双页模式
/// - Returns: 下一页页码,nil 表示没有下一页
private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
// 末尾空白页之后没有更多页
if pageNum == RDReaderView.blankEndPageNum {
return nil
}
if isDualPage, let coverIndex = coverPageIndex {
if pageNum == coverIndex {
return RDReaderView.blankPageNum
}
if pageNum == RDReaderView.blankPageNum {
let firstContent = coverIndex + 1
return firstContent < totalPages ? firstContent : nil
}
}
let next = pageNum + 1
if next < totalPages {
return next
}
// pageNum 是最后一页,检查在双页模式下是否需要空白页配对
if isDualPage {
if let coverIndex = coverPageIndex {
// 有封面时:封面之后的页面两两配对,偶数偏移=左页需要配对
let adjustedIndex = pageNum - (coverIndex + 1)
if adjustedIndex >= 0 && adjustedIndex % 2 == 0 {
return RDReaderView.blankEndPageNum
}
} else {
// 无封面:偶数索引=左页需要配对
if pageNum % 2 == 0 {
return RDReaderView.blankEndPageNum
}
}
}
return nil
}
/// 计算某页的"上一页"页码
/// 考虑封面页独占和末尾空白页配对的情况
/// - Parameters:
/// - pageNum: 当前页码
/// - isDualPage: 是否双页模式
/// - Returns: 上一页页码,nil 表示没有上一页
private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
// 末尾空白页的前一页是最后一个真实页
if pageNum == RDReaderView.blankEndPageNum {
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
return totalPages > 0 ? totalPages - 1 : nil
}
if isDualPage, let coverIndex = coverPageIndex {
if pageNum == RDReaderView.blankPageNum {
return coverIndex
}
if pageNum == coverIndex + 1 {
return RDReaderView.blankPageNum
}
}
let prev = pageNum - 1
return prev >= 0 ? prev : nil
}
/// UIPageViewController 数据源:返回当前页之前(左/上)的页面控制器
/// RTL 模式下 before/after 语义互换:before(向右翻)= 下一页
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
let isDualPage = landscapeDualPageEnabled && isLandscape
let isRTL = pageDirection == .rightToLeft
// RTL 时 before/after 语义互换:before(向右翻)= 下一页
let targetNum = isRTL
? nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
: prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
guard let num = targetNum else { return nil }
let targetVC = makeSinglePageChildVC(for: num)
willPreviousTransitionToViewController = targetVC
return targetVC
}
/// UIPageViewController 数据源:返回当前页之后(右/下)的页面控制器
/// RTL 模式下 before/after 语义互换
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let vc = viewController as? RDReaderPageChildViewController else { return nil }
let isDualPage = landscapeDualPageEnabled && isLandscape
let isRTL = pageDirection == .rightToLeft
let targetNum = isRTL
? prevPageNum(before: vc.pageNum, isDualPage: isDualPage)
: nextPageNum(after: vc.pageNum, isDualPage: isDualPage)
guard let num = targetNum else { return nil }
let targetVC = makeSinglePageChildVC(for: num)
willNextTransitionToViewController = targetVC
return targetVC
}
/// UIPageViewController 代理:翻页动画完成回调
/// 更新当前页码,触发预加载,并检测 PageViewController 异常状态
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController {
let pn = firstVC.pageNum
if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum {
currentPage = pn
primePageCache(around: pn, preferredForward: predictedPageDirection)
}
}
predictedPageDirection = nil
if detectPageViewControllerFault(pageViewController) {
patchPageViewControllerFault()
}
}
/// UIPageViewController 代理:即将开始转场动画
/// 记录预测的翻页方向,用于优化预加载策略
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
willTransitionToViewController = pendingViewControllers.first
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
target.pageNum != RDReaderView.blankPageNum,
target.pageNum != RDReaderView.blankEndPageNum {
predictedPageDirection = target.pageNum >= currentPage
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
}
}
}
/// UICollectionView 数据源和自定义布局代理实现
/// 处理水平滚动和垂直滚动两种翻页模式
extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure {
/// 配置 UICollectionViewCell
/// 通过 DataSource 获取页面内容视图,支持 cell 复用和预加载视图复用
/// RTL 水平模式下翻转 cell 内容使文字方向正常
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if let identifer = self.dataSource?.pageIdentifier(readerView: self, pageNum: indexPath.row) {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell
let preloadedView = preloadedPageViews.removeValue(forKey: indexPath.row)
preloadedView?.removeFromSuperview()
let reusableView = cell.containerView ?? preloadedView
let conttainerView = self.dataSource?.pageContentView(readerView: self, pageNum: indexPath.row, containerView: reusableView)
if let conttainerView = conttainerView {
cell.containerView = conttainerView
}
// RTL 水平模式:翻转 cell 内容使文字方向正常(collectionView 已整体翻转)
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
} else {
cell.contentView.transform = .identity
}
return cell
}
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: NSStringFromClass(UICollectionViewCell.self), for: indexPath)
return cell
}
/// 返回每组的 item 数量,即总页数
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.dataSource?.pageCountOfReaderView(readerView: self) ?? 0
}
/// 布局代理回调:当前可见页码变化时通知
public func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int) {
currentPage = pageIndex
primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
}
/// 布局数据源回调:返回垂直滚动模式下指定页的高度
/// 当前返回 nil 使用默认高度
public func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat? {
return nil
}
}
/// 内容视图注册和复用扩展
/// 提供类似 UITableView 的 register/dequeueReusable 机制
extension RDReaderView {
/// 注册内容视图类型
/// - Parameters:
/// - contentView: 内容视图的类型
/// - identifier: 重用标识符
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
contentViews[identifier] = contentView
collectionView.register(RDReaderContentCell.self, forCellWithReuseIdentifier: identifier)
}
/// 获取可复用的内容视图
/// 优先从当前显示的 cell 中获取,其次创建新实例
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView {
if self.currentDisplayType != .pageCurl, let cell = self.collectionView.cellForItem(at: IndexPath(row: pageNum, section: 0)) as? RDReaderContentCell, let containerView = cell.containerView {
return containerView
}
let contentViewClass = contentViews[identifier]
assert(contentViewClass != nil, "请调用register(contentViewcontentViewWithReuseIdentifier:)")
var contentView = contentViewClass!.init()
return contentView
}
/// 获取指定页码的内容视图
/// pageCurl 模式从 PageViewController 获取,滚动模式从 CollectionView cell 获取
public func pageContentView(pageNum: Int) -> UIView? {
if currentDisplayType == .pageCurl {
return (self.pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.contentView
} else {
let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDReaderContentCell
return cell?.containerView
}
}
}
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
}
}