refactor: split reader architecture and chrome handling
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import UIKit
|
||||
|
||||
/// 翻页控制器:管理 UIPageViewController 的创建、切换、故障修复和翻页请求队列。
|
||||
///
|
||||
/// RDReaderView 持有此控制器,将 pageCurl 模式的容器管理职责下沉。
|
||||
struct RDReaderPagingController {
|
||||
/// 当前 pageCurl 翻页请求模型
|
||||
struct PageTransitionRequest: Equatable {
|
||||
let pageNum: Int
|
||||
let animated: Bool
|
||||
}
|
||||
|
||||
/// 排队中的翻页请求(pageCurl 动画过程中收到新跳转时排队)
|
||||
var pendingTransitionRequest: PageTransitionRequest?
|
||||
|
||||
/// 是否正在执行页面转场动画
|
||||
var isTransitioning: Bool = false
|
||||
|
||||
/// UI 是否已经构建完成(避免重复构建)
|
||||
var didBuildUI = false
|
||||
|
||||
// MARK: - PageViewController 工厂
|
||||
|
||||
/// 创建带指定 spine 位置的 UIPageViewController
|
||||
static 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.isDoubleSided = isDualPage
|
||||
return pageVC
|
||||
}
|
||||
|
||||
// MARK: - 翻页请求队列管理
|
||||
|
||||
/// pageCurl 动画过程中如果收到新的跳转请求,则排队等待当前动画结束。
|
||||
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDReaderView.DisplayType) -> Bool {
|
||||
guard currentDisplayType == .pageCurl, isTransitioning else { return false }
|
||||
pendingTransitionRequest = request
|
||||
return true
|
||||
}
|
||||
|
||||
/// 收尾 pageCurl 转场,返回排队中的后续请求(如果有的话)。
|
||||
mutating func finishPageCurlTransition() -> PageTransitionRequest? {
|
||||
isTransitioning = false
|
||||
guard let pending = pendingTransitionRequest else { return nil }
|
||||
pendingTransitionRequest = nil
|
||||
return pending
|
||||
}
|
||||
|
||||
/// 重置所有排队状态(用于故障修复)。
|
||||
mutating func resetPendingState() {
|
||||
isTransitioning = false
|
||||
pendingTransitionRequest = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import UIKit
|
||||
|
||||
final class RDReaderPreloadController {
|
||||
var radius: Int = 1
|
||||
|
||||
private let preloadHostView = UIView()
|
||||
private var preloadedPageViews: [Int: UIView] = [:]
|
||||
private var pageCurlCachedViews: [Int: UIView] = [:]
|
||||
private var cacheSignature: CacheSignature?
|
||||
|
||||
struct Environment {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
let isLandscape: Bool
|
||||
let pagesPerScreen: Int
|
||||
let boundsSize: CGSize
|
||||
let landscapeDualPageEnabled: Bool
|
||||
let coverPageIndex: Int?
|
||||
let totalPages: Int
|
||||
let spreadResolver: RDReaderSpreadResolver
|
||||
}
|
||||
|
||||
private struct CacheSignature: Equatable {
|
||||
let displayType: RDReaderView.DisplayType
|
||||
let isLandscape: Bool
|
||||
let pagesPerScreen: Int
|
||||
let boundsSize: CGSize
|
||||
}
|
||||
|
||||
func setHostFrame(_ frame: CGRect) {
|
||||
preloadHostView.frame = frame
|
||||
}
|
||||
|
||||
func ensureHostView(in parentView: UIView) {
|
||||
guard preloadHostView.superview == nil else { return }
|
||||
preloadHostView.isHidden = true
|
||||
preloadHostView.isUserInteractionEnabled = false
|
||||
preloadHostView.clipsToBounds = true
|
||||
preloadHostView.frame = parentView.bounds
|
||||
preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
parentView.insertSubview(preloadHostView, at: 0)
|
||||
}
|
||||
|
||||
func initializeSignature(_ environment: Environment) {
|
||||
cacheSignature = currentCacheSignature(environment)
|
||||
}
|
||||
|
||||
func invalidate(environment: Environment) {
|
||||
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
||||
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
||||
pageCurlCachedViews.removeAll()
|
||||
preloadedPageViews.removeAll()
|
||||
cacheSignature = currentCacheSignature(environment)
|
||||
}
|
||||
|
||||
func pageViewForDisplay(
|
||||
pageNum: Int,
|
||||
environment: Environment,
|
||||
contentViewProvider: (Int, UIView?) -> UIView?
|
||||
) -> UIView {
|
||||
let reusableView = detachedReusablePageView(for: pageNum)
|
||||
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
|
||||
pageCurlCachedViews[pageNum] = view
|
||||
return view
|
||||
}
|
||||
|
||||
func takePreloadedView(for pageNum: Int) -> UIView? {
|
||||
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
||||
preloaded?.removeFromSuperview()
|
||||
return preloaded
|
||||
}
|
||||
|
||||
func prime(
|
||||
around pageNum: Int,
|
||||
preferredForward: Bool? = nil,
|
||||
parentView: UIView,
|
||||
environment: Environment,
|
||||
contentViewProvider: (Int, UIView?) -> UIView?
|
||||
) {
|
||||
refreshCacheSignatureIfNeeded(environment)
|
||||
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
|
||||
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment))
|
||||
trimCachedPageViews(keeping: keepSet)
|
||||
guard !targets.isEmpty else { return }
|
||||
|
||||
ensureHostView(in: parentView)
|
||||
preloadHostView.frame = parentView.bounds
|
||||
|
||||
for targetPage in targets {
|
||||
let existing = detachedReusablePageView(for: targetPage)
|
||||
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
|
||||
preloadedPageViews[targetPage] = contentView
|
||||
if contentView.superview !== preloadHostView {
|
||||
contentView.removeFromSuperview()
|
||||
preloadHostView.addSubview(contentView)
|
||||
}
|
||||
contentView.frame = preloadHostView.bounds
|
||||
}
|
||||
}
|
||||
|
||||
private func currentCacheSignature(_ environment: Environment) -> CacheSignature {
|
||||
CacheSignature(
|
||||
displayType: environment.displayType,
|
||||
isLandscape: environment.isLandscape,
|
||||
pagesPerScreen: environment.pagesPerScreen,
|
||||
boundsSize: environment.boundsSize
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshCacheSignatureIfNeeded(_ environment: Environment) {
|
||||
let signature = currentCacheSignature(environment)
|
||||
if cacheSignature != signature {
|
||||
invalidate(environment: environment)
|
||||
} else if cacheSignature == nil {
|
||||
cacheSignature = signature
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldCachePage(_ pageNum: Int, environment: Environment) -> Bool {
|
||||
pageNum >= 0
|
||||
&& pageNum != RDReaderView.blankPageNum
|
||||
&& pageNum != RDReaderView.blankEndPageNum
|
||||
&& pageNum < environment.totalPages
|
||||
}
|
||||
|
||||
private func isDualPage(environment: Environment) -> Bool {
|
||||
environment.landscapeDualPageEnabled
|
||||
&& environment.isLandscape
|
||||
&& environment.displayType != .verticalScroll
|
||||
}
|
||||
|
||||
private func spreadStart(for pageNum: Int, environment: Environment) -> Int? {
|
||||
guard shouldCachePage(pageNum, environment: environment) else { return nil }
|
||||
guard isDualPage(environment: environment) else { return pageNum }
|
||||
return environment.spreadResolver.dualPagePair(
|
||||
for: pageNum,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex
|
||||
).left
|
||||
}
|
||||
|
||||
private func spreadPageNumbers(startingAt pageNum: Int, environment: Environment) -> Set<Int> {
|
||||
guard shouldCachePage(pageNum, environment: environment) else { return [] }
|
||||
guard isDualPage(environment: environment) else { return [pageNum] }
|
||||
let pair = environment.spreadResolver.dualPagePair(
|
||||
for: pageNum,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex
|
||||
)
|
||||
var pages: Set<Int> = [pair.left]
|
||||
if let right = pair.right, shouldCachePage(right, environment: environment) {
|
||||
pages.insert(right)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
private func adjacentSpreadStart(from pageNum: Int, forward: Bool, environment: Environment) -> Int? {
|
||||
guard environment.totalPages > 0,
|
||||
let spreadStart = spreadStart(for: pageNum, environment: environment) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard isDualPage(environment: environment) else {
|
||||
let target = forward ? spreadStart + 1 : spreadStart - 1
|
||||
return shouldCachePage(target, environment: environment) ? target : nil
|
||||
}
|
||||
|
||||
if forward {
|
||||
return environment.spreadResolver.adjacentDualPage(
|
||||
from: spreadStart,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex,
|
||||
forward: true
|
||||
)
|
||||
}
|
||||
|
||||
let pair = environment.spreadResolver.dualPagePair(
|
||||
for: spreadStart,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex
|
||||
)
|
||||
let prevEnd = pair.left - 1
|
||||
guard prevEnd >= 0 else { return nil }
|
||||
return environment.spreadResolver.dualPagePair(
|
||||
for: prevEnd,
|
||||
totalPages: environment.totalPages,
|
||||
coverPageIndex: environment.coverPageIndex
|
||||
).left
|
||||
}
|
||||
|
||||
private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set<Int> {
|
||||
spreadPageNumbers(startingAt: anchorPage, environment: environment)
|
||||
}
|
||||
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
private func forecastTargets(around pageNum: Int, preferredForward: Bool?, environment: Environment) -> [Int] {
|
||||
guard environment.totalPages > 0,
|
||||
shouldCachePage(pageNum, environment: environment) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var targets = Set<Int>()
|
||||
|
||||
var previousAnchor = pageNum
|
||||
for _ in 0..<max(radius, 0) {
|
||||
guard let previous = adjacentSpreadStart(from: previousAnchor, forward: false, environment: environment) else { break }
|
||||
targets.formUnion(spreadPageNumbers(startingAt: previous, environment: environment))
|
||||
previousAnchor = previous
|
||||
}
|
||||
|
||||
var nextAnchor = pageNum
|
||||
for _ in 0..<max(radius, 0) {
|
||||
guard let next = adjacentSpreadStart(from: nextAnchor, forward: true, environment: environment) else { break }
|
||||
targets.formUnion(spreadPageNumbers(startingAt: next, environment: environment))
|
||||
nextAnchor = next
|
||||
}
|
||||
|
||||
if let preferredForward,
|
||||
let edgeAnchor = preferredForward ? adjacentSpreadStart(from: nextAnchor, forward: true, environment: environment)
|
||||
: adjacentSpreadStart(from: previousAnchor, forward: false, environment: environment) {
|
||||
targets.formUnion(spreadPageNumbers(startingAt: edgeAnchor, environment: environment))
|
||||
}
|
||||
|
||||
return targets.sorted()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
struct RDReaderSpreadResolver {
|
||||
func isFullScreenPage(
|
||||
_ pageNum: Int,
|
||||
landscapeDualPageEnabled: Bool,
|
||||
isLandscape: Bool,
|
||||
coverPageIndex: Int?
|
||||
) -> Bool {
|
||||
guard landscapeDualPageEnabled, isLandscape, let coverIndex = coverPageIndex else { return false }
|
||||
return pageNum == coverIndex
|
||||
}
|
||||
|
||||
func dualPagePair(
|
||||
for pageNum: Int,
|
||||
totalPages: Int,
|
||||
coverPageIndex: Int?
|
||||
) -> (left: Int, right: Int?) {
|
||||
if let coverIndex = coverPageIndex {
|
||||
if pageNum == coverIndex {
|
||||
return (coverIndex, nil)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
let left = (pageNum / 2) * 2
|
||||
let right = left + 1 < totalPages ? left + 1 : nil
|
||||
return (left, right)
|
||||
}
|
||||
|
||||
func adjacentDualPage(
|
||||
from pageNum: Int,
|
||||
totalPages: Int,
|
||||
coverPageIndex: Int?,
|
||||
forward: Bool
|
||||
) -> Int? {
|
||||
let pair = dualPagePair(for: pageNum, totalPages: totalPages, coverPageIndex: coverPageIndex)
|
||||
if forward {
|
||||
let nextStart = (pair.right ?? pair.left) + 1
|
||||
return nextStart < totalPages ? nextStart : nil
|
||||
}
|
||||
|
||||
let prevEnd = pair.left - 1
|
||||
guard prevEnd >= 0 else { return nil }
|
||||
return dualPagePair(for: prevEnd, totalPages: totalPages, coverPageIndex: coverPageIndex).left
|
||||
}
|
||||
|
||||
func nextPage(
|
||||
from currentPage: Int,
|
||||
totalPages: Int,
|
||||
pagesPerScreen: Int,
|
||||
coverPageIndex: Int?,
|
||||
forward: Bool
|
||||
) -> Int? {
|
||||
if pagesPerScreen > 1 {
|
||||
return adjacentDualPage(
|
||||
from: currentPage,
|
||||
totalPages: totalPages,
|
||||
coverPageIndex: coverPageIndex,
|
||||
forward: forward
|
||||
)
|
||||
}
|
||||
|
||||
let target = currentPage + (forward ? 1 : -1)
|
||||
guard target >= 0, target < totalPages else { return nil }
|
||||
return target
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import CoreGraphics
|
||||
|
||||
struct RDReaderTapRegionHandler {
|
||||
func resolveTapEvent(
|
||||
point: CGPoint,
|
||||
viewFrame: CGRect,
|
||||
isToolViewVisible: Bool
|
||||
) -> RDReaderView.TapEvent {
|
||||
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) {
|
||||
return isToolViewVisible ? .center : .left
|
||||
}
|
||||
|
||||
if centerFrame.contains(point) {
|
||||
return .center
|
||||
}
|
||||
|
||||
if rightFrame.contains(point) {
|
||||
return isToolViewVisible ? .center : .right
|
||||
}
|
||||
|
||||
return .none
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import UIKit
|
||||
|
||||
/// UICollectionView 数据源和自定义布局代理实现
|
||||
/// 处理水平滚动和垂直滚动两种翻页模式
|
||||
extension RDReaderView: UICollectionViewDataSource, RDReaderFlowLayoutDelegate, RDReaderFlowLayoutDataSoure {
|
||||
/// 配置 UICollectionViewCell
|
||||
/// 通过 DataSource 获取页面内容视图,支持 cell 复用和预加载视图复用
|
||||
/// RTL 水平模式下翻转 cell 内容使文字方向正常
|
||||
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
|
||||
if let identifer = pageReuseIdentifier(for: indexPath.row) {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDReaderContentCell
|
||||
let preloadedView = preloadController.takePreloadedView(for: indexPath.row)
|
||||
let reusableView = cell.containerView ?? preloadedView
|
||||
cell.containerView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
||||
// 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 numberOfPages()
|
||||
}
|
||||
|
||||
/// 布局代理回调:当前可见页码变化时通知
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import UIKit
|
||||
|
||||
/// 内容视图注册和复用扩展
|
||||
/// 提供类似 UITableView 的 register/dequeueReusable 机制
|
||||
extension RDReaderView {
|
||||
/// 注册内容视图类型
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import UIKit
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 计算某页的"下一页"页码
|
||||
/// 考虑封面页独占和末尾空白页配对的情况
|
||||
private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
let totalPages = numberOfPages()
|
||||
|
||||
// 末尾空白页之后没有更多页
|
||||
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
|
||||
}
|
||||
|
||||
/// 计算某页的"上一页"页码
|
||||
/// 考虑封面页独占和末尾空白页配对的情况
|
||||
private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
if pageNum == RDReaderView.blankEndPageNum {
|
||||
let totalPages = numberOfPages()
|
||||
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
|
||||
|
||||
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]) {
|
||||
pagingController.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import UIKit
|
||||
|
||||
/// 工具栏管理扩展:处理工具栏的显示/隐藏动画、安装、布局
|
||||
extension RDReaderView {
|
||||
|
||||
/// 点击屏幕中央区域,切换工具栏的显示/隐藏
|
||||
func tapCenter() {
|
||||
refreshToolViewsFromProviderIfNeeded()
|
||||
print("[Debug] tapCenter called, isShowToolView was: \(isShowToolView), topToolView: \(String(describing: topToolView)), onBack: \(String(describing: (topToolView as? RDEPUBReaderTopToolView)?.onBack))")
|
||||
isShowToolView = !isShowToolView
|
||||
if isShowToolView {
|
||||
if let topToolView = topToolView {
|
||||
installToolViewIfNeeded(topToolView, position: .top)
|
||||
layoutIfNeeded()
|
||||
topToolView.transform = CGAffineTransform(translationX: 0, y: -topToolView.bounds.height)
|
||||
UIView.animate(withDuration: toolViewAnimationDuration) {
|
||||
topToolView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
if let bottomToolView = bottomToolView {
|
||||
installToolViewIfNeeded(bottomToolView, position: .bottom)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断点击命中的视图是否在指定工具栏内
|
||||
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)
|
||||
}
|
||||
|
||||
enum ToolViewPosition {
|
||||
case top
|
||||
case bottom
|
||||
}
|
||||
|
||||
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) {
|
||||
guard toolView.superview !== self else { return }
|
||||
|
||||
toolView.removeFromSuperview()
|
||||
addSubview(toolView)
|
||||
toolView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let heightConstraint: NSLayoutConstraint
|
||||
switch position {
|
||||
case .top:
|
||||
topToolViewHeightConstraint?.isActive = false
|
||||
heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .top))
|
||||
topToolViewHeightConstraint = heightConstraint
|
||||
NSLayoutConstraint.activate([
|
||||
toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
toolView.topAnchor.constraint(equalTo: topAnchor),
|
||||
heightConstraint
|
||||
])
|
||||
case .bottom:
|
||||
bottomToolViewHeightConstraint?.isActive = false
|
||||
heightConstraint = toolView.heightAnchor.constraint(equalToConstant: resolvedToolViewHeight(for: .bottom))
|
||||
bottomToolViewHeightConstraint = heightConstraint
|
||||
NSLayoutConstraint.activate([
|
||||
toolView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
toolView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
toolView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
heightConstraint
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func updateToolViewHeightConstraintsIfNeeded() {
|
||||
topToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .top)
|
||||
bottomToolViewHeightConstraint?.constant = resolvedToolViewHeight(for: .bottom)
|
||||
}
|
||||
|
||||
private func resolvedToolViewHeight(for position: ToolViewPosition) -> CGFloat {
|
||||
let contentHeight: CGFloat = 52
|
||||
switch position {
|
||||
case .top:
|
||||
return safeAreaInsets.top + contentHeight
|
||||
case .bottom:
|
||||
return safeAreaInsets.bottom + contentHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
import UIKit
|
||||
|
||||
/// 阅读器数据源协议
|
||||
/// 上层控制器通过实现此协议,向 RDReaderView 提供页面数量、内容视图和工具栏。
|
||||
/// 所有方法由 RDReaderView 在需要渲染页面时回调。
|
||||
@objc public protocol RDReaderDataSource: NSObjectProtocol {
|
||||
/// 返回阅读器的总页数
|
||||
func pageCountOfReaderView(readerView: RDReaderView) -> Int
|
||||
/// 返回指定页码的内容视图
|
||||
func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
/// 返回指定页码的唯一标识符,用于 UICollectionViewCell 复用
|
||||
func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String?
|
||||
/// 返回顶部工具栏视图(可选),点击屏幕中央时会显示/隐藏
|
||||
@objc optional func topToolView(readerView: RDReaderView) -> UIView?
|
||||
/// 返回底部工具栏视图(可选),点击屏幕中央时会显示/隐藏
|
||||
@objc optional func bottomToolView(readerView: RDReaderView) -> UIView?
|
||||
}
|
||||
|
||||
/// 与内容格式无关的统一分页提供者协议。
|
||||
/// 逐步替代面向 EPUB 命名的 ``RDReaderDataSource``,方便后续复用到 PDF 等其他阅读场景。
|
||||
@objc public protocol RDReaderPageProvider: NSObjectProtocol {
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
@objc optional func pageIdentifier(in readerView: RDReaderView, index: Int) -> String?
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDReaderView) -> UIView?
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView?
|
||||
}
|
||||
|
||||
/// 阅读器代理协议
|
||||
/// 上层控制器通过实现此协议,接收翻页和横竖屏切换事件
|
||||
@objc public protocol RDReaderDelegate: NSObjectProtocol {
|
||||
/// 翻页回调,当当前显示页码变化时触发
|
||||
func pageNum(readerView: RDReaderView, pageNum: Int)
|
||||
/// 横竖屏切换时回调,可在此重新分页
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool)
|
||||
}
|
||||
|
||||
public protocol RDReaderPageNavigating: AnyObject {
|
||||
var currentPage: Int { get }
|
||||
func reloadPages()
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
|
||||
// MARK: - 枚举
|
||||
|
||||
extension RDReaderView {
|
||||
/// 翻页模式枚举,决定 RDReaderView 使用哪种底层视图来展示内容
|
||||
public enum DisplayType {
|
||||
/// 仿真翻页:使用 UIPageViewController,模拟真实翻书效果
|
||||
case pageCurl
|
||||
/// 水平滚动:使用 UICollectionView + 自定义布局,每屏一项,水平分页
|
||||
case horizontalScroll
|
||||
/// 垂直滚动:使用 UICollectionView,全宽项目,垂直连续滚动
|
||||
case verticalScroll
|
||||
}
|
||||
|
||||
/// 翻页方向枚举
|
||||
public enum PageDirection {
|
||||
/// 从左往右翻页(默认,适用于中文/英文书籍)
|
||||
case leftToRight
|
||||
/// 从右往左翻页(适用于日文漫画等)
|
||||
case rightToLeft
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy 适配器
|
||||
|
||||
final class RDReaderLegacyDataSourceAdapter: NSObject, RDReaderPageProvider {
|
||||
weak var dataSource: RDReaderDataSource?
|
||||
|
||||
func numberOfPages(in readerView: RDReaderView) -> Int {
|
||||
dataSource?.pageCountOfReaderView(readerView: readerView) ?? 0
|
||||
}
|
||||
|
||||
func readerView(_ readerView: RDReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
|
||||
dataSource?.pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) ?? UIView()
|
||||
}
|
||||
|
||||
func pageIdentifier(in readerView: RDReaderView, index: Int) -> String? {
|
||||
dataSource?.pageIdentifier(readerView: readerView, pageNum: index)
|
||||
}
|
||||
|
||||
func readerViewTopChrome(_ readerView: RDReaderView) -> UIView? {
|
||||
dataSource?.topToolView?(readerView: readerView)
|
||||
}
|
||||
|
||||
func readerViewBottomChrome(_ readerView: RDReaderView) -> UIView? {
|
||||
dataSource?.bottomToolView?(readerView: readerView)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user