1330 lines
58 KiB
Swift
1330 lines
58 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 提供页面数量、内容视图和工具栏。
|
||
/// 所有方法由 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
|
||
}
|
||
}
|
||
|
||
/// 将页码钳制到当前书籍的合法范围内
|
||
private func clampedPageNumber(_ pageNum: Int) -> Int? {
|
||
let totalPages = dataSource?.pageCountOfReaderView(readerView: self) ?? 0
|
||
guard totalPages > 0 else { return nil }
|
||
return min(max(pageNum, 0), totalPages - 1)
|
||
}
|
||
|
||
/// 上一次检测到的横竖屏状态,用于在 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
|
||
}
|
||
|
||
/// 当 pageCurl 正在转场时,后续跳转请求会先排队,待当前动画稳定后再执行。
|
||
private struct PageTransitionRequest: Equatable {
|
||
let pageNum: Int
|
||
let animated: Bool
|
||
}
|
||
|
||
private var pendingTransitionRequest: PageTransitionRequest?
|
||
|
||
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.isTransitioning = false
|
||
self.pendingTransitionRequest = nil
|
||
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 {
|
||
guard currentDisplayType == .pageCurl, isTransitioning else { return false }
|
||
pendingTransitionRequest = request
|
||
return true
|
||
}
|
||
|
||
/// 收尾 pageCurl 转场,并尝试执行排队中的后续跳转。
|
||
private func finishPageCurlTransition(repairFaultIfNeeded: Bool = true) {
|
||
isTransitioning = false
|
||
willPreviousTransitionToViewController = nil
|
||
willNextTransitionToViewController = nil
|
||
willTransitionToViewController = nil
|
||
|
||
if repairFaultIfNeeded, detectPageViewControllerFault(pageViewController) {
|
||
patchPageViewControllerFault()
|
||
return
|
||
}
|
||
|
||
guard let pendingTransitionRequest else { return }
|
||
self.pendingTransitionRequest = nil
|
||
transitionToPage(pageNum: pendingTransitionRequest.pageNum, animated: pendingTransitionRequest.animated)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
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()
|
||
}
|
||
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) {
|
||
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 = 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
|
||
finishPageCurlTransition()
|
||
}
|
||
|
||
/// UIPageViewController 代理:即将开始转场动画
|
||
/// 记录预测的翻页方向,用于优化预加载策略
|
||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||
isTransitioning = true
|
||
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) {
|
||
if currentPage >= 0, currentPage != pageIndex {
|
||
predictedPageDirection = pageIndex >= currentPage
|
||
}
|
||
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(contentView:contentViewWithReuseIdentifier:)")
|
||
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
|
||
}
|
||
}
|
||
|
||
/// 返回当前阅读模式下单页内容容器的实际尺寸。
|
||
/// 优先使用已经显示出来的内容视图尺寸;若页面尚未装载,则回退到当前模式下的理论单页尺寸。
|
||
public func resolvedSinglePageSize(pageNum: Int? = nil) -> CGSize {
|
||
let targetPage = pageNum ?? (currentPage >= 0 ? currentPage : nil)
|
||
if let targetPage,
|
||
let contentView = pageContentView(pageNum: targetPage),
|
||
contentView.bounds.width > 0,
|
||
contentView.bounds.height > 0 {
|
||
return contentView.bounds.size
|
||
}
|
||
|
||
if currentDisplayType == .pageCurl {
|
||
if let childViewController = pageViewController.viewControllers?.first as? RDReaderPageChildViewController,
|
||
childViewController.view.bounds.width > 0,
|
||
childViewController.view.bounds.height > 0 {
|
||
return childViewController.view.bounds.size
|
||
}
|
||
if pageViewController.view.bounds.width > 0, pageViewController.view.bounds.height > 0 {
|
||
return pageViewController.view.bounds.size
|
||
}
|
||
return bounds.size
|
||
}
|
||
|
||
let containerBounds = collectionView.bounds.size == .zero ? bounds.size : collectionView.bounds.size
|
||
guard containerBounds.width > 0, containerBounds.height > 0 else {
|
||
return bounds.size
|
||
}
|
||
|
||
switch currentDisplayType {
|
||
case .horizontalScroll:
|
||
return CGSize(
|
||
width: containerBounds.width / CGFloat(max(pagesPerScreen, 1)),
|
||
height: containerBounds.height
|
||
)
|
||
case .verticalScroll:
|
||
return containerBounds
|
||
case .pageCurl:
|
||
return bounds.size
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
}
|