// // 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 { guard shouldCachePage(pageNum) else { return [] } let isDualPage = landscapeDualPageEnabled && isLandscape && currentDisplayType != .verticalScroll guard isDualPage else { return [pageNum] } let pair = dualPagePair(for: pageNum) var pages: Set = [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 { spreadPageNumbers(startingAt: anchorPage) } /// 获取用于显示的页面视图 /// 优先从缓存中获取,其次从预加载缓存中获取,最后新建 private func pageViewForDisplay(pageNum: Int) -> UIView { let reusableView = detachedReusablePageView(for: pageNum) let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: reusableView) ?? reusableView ?? UIView() pageCurlCachedViews[pageNum] = view return view } /// 返回可安全复用的 page view。 /// 只有已经脱离视图层级的 view 才能复用,避免 pageCurl 转场期间同一个 UIView /// 被多个 child controller 争用,从而导致前一个页面瞬时空白。 private func detachedReusablePageView(for pageNum: Int) -> UIView? { if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) { preloaded.removeFromSuperview() return preloaded } guard let cached = pageCurlCachedViews[pageNum], cached.superview == nil else { return nil } pageCurlCachedViews.removeValue(forKey: pageNum) return cached } /// 裁剪缓存,只保留指定页码集合中的页面 /// 不在保留集合中的页面会被从视图层级中移除 private func trimCachedPageViews(keeping pageNumbers: Set) { 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() var previousAnchor = pageNum for _ in 0.. 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 } }