refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import UIKit
|
||||
|
||||
struct RDEpubReaderPagingController {
|
||||
|
||||
struct PageTransitionRequest: Equatable {
|
||||
let pageNum: Int
|
||||
let animated: Bool
|
||||
}
|
||||
|
||||
var pendingTransitionRequest: PageTransitionRequest?
|
||||
|
||||
var isTransitioning: Bool = false
|
||||
|
||||
var didBuildUI = false
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
mutating func shouldQueuePageTransition(_ request: PageTransitionRequest, currentDisplayType: RDEpubReaderView.DisplayType) -> Bool {
|
||||
guard currentDisplayType == .pageCurl, isTransitioning else { return false }
|
||||
pendingTransitionRequest = request
|
||||
return true
|
||||
}
|
||||
|
||||
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,366 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol RDEpubReaderCachePolicyProviding {
|
||||
var shouldAvoidReaderPageCaching: Bool { get }
|
||||
}
|
||||
|
||||
/// 页面视图被缓存彻底丢弃(旋转失效、窗口裁剪)时的即时清理钩子,
|
||||
/// 让持有重资源的页面(如活 WKWebView)不必等 dealloc 就能拆除内容。
|
||||
/// 实现方在之后再次被 configure 时必须能恢复可用。
|
||||
protocol RDEpubReaderPageResourceReleasing {
|
||||
func releaseResources()
|
||||
}
|
||||
|
||||
final class RDEpubReaderPreloadController {
|
||||
|
||||
var radius: Int = 2
|
||||
|
||||
private let preloadHostView = UIView()
|
||||
|
||||
private var preloadedPageViews: [Int: UIView] = [:]
|
||||
|
||||
private var pageCurlCachedViews: [Int: UIView] = [:]
|
||||
|
||||
private var cacheSignature: CacheSignature?
|
||||
|
||||
struct Environment {
|
||||
|
||||
let displayType: RDEpubReaderView.DisplayType
|
||||
|
||||
let isLandscape: Bool
|
||||
|
||||
let pagesPerScreen: Int
|
||||
|
||||
let boundsSize: CGSize
|
||||
|
||||
let landscapeDualPageEnabled: Bool
|
||||
|
||||
let coverPageIndex: Int?
|
||||
|
||||
let totalPages: Int
|
||||
|
||||
let spreadResolver: RDEpubReaderSpreadResolver
|
||||
}
|
||||
|
||||
private struct CacheSignature: Equatable {
|
||||
|
||||
let displayType: RDEpubReaderView.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) {
|
||||
RDEpubReaderTapDebug.log(
|
||||
"PreloadController.invalidate",
|
||||
"clearing pageCurlCached=\(pageCurlCachedViews.keys.sorted()) preloaded=\(preloadedPageViews.keys.sorted())"
|
||||
)
|
||||
pageCurlCachedViews.values.forEach { discard($0) }
|
||||
preloadedPageViews.values.forEach { discard($0) }
|
||||
pageCurlCachedViews.removeAll()
|
||||
preloadedPageViews.removeAll()
|
||||
cacheSignature = currentCacheSignature(environment)
|
||||
}
|
||||
|
||||
func pageViewForDisplay(
|
||||
pageNum: Int,
|
||||
environment: Environment,
|
||||
contentViewProvider: (Int, UIView?) -> UIView?
|
||||
) -> UIView {
|
||||
let view: UIView
|
||||
if let reusableView = detachedReusablePageView(for: pageNum) {
|
||||
view = reusableView
|
||||
RDEpubReaderTapDebug.log(
|
||||
"PreloadController.pageViewForDisplay",
|
||||
"cache HIT page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(reusableView))"
|
||||
)
|
||||
} else {
|
||||
view = contentViewProvider(pageNum, nil) ?? UIView()
|
||||
RDEpubReaderTapDebug.log(
|
||||
"PreloadController.pageViewForDisplay",
|
||||
"cache MISS page=\(pageNum) created=\(RDEpubReaderTapDebug.describe(view))"
|
||||
)
|
||||
}
|
||||
if shouldCache(view: view, for: pageNum, environment: environment) {
|
||||
pageCurlCachedViews[pageNum] = view
|
||||
} else {
|
||||
pageCurlCachedViews.removeValue(forKey: pageNum)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func takePreloadedView(for pageNum: Int) -> UIView? {
|
||||
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
||||
preloaded?.removeFromSuperview()
|
||||
RDEpubReaderTapDebug.log(
|
||||
"PreloadController.takePreloadedView",
|
||||
"cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(preloaded))"
|
||||
)
|
||||
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 {
|
||||
if let liveCached = pageCurlCachedViews[targetPage],
|
||||
liveCached.superview != nil,
|
||||
liveCached.superview !== preloadHostView {
|
||||
// 该页视图仍挂在翻页控制器的相邻页缓存上,内容已就绪。
|
||||
// 以前这里会误删缓存条目并创建一个从零加载的副本顶替,
|
||||
// 重内容页翻过去时拿到的是仍在加载的副本,表现为翻页后闪烁重置。
|
||||
RDEpubReaderTapDebug.log(
|
||||
"PreloadController.prime",
|
||||
"skip live page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(liveCached))"
|
||||
)
|
||||
continue
|
||||
}
|
||||
let contentView: UIView
|
||||
if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView {
|
||||
contentView = existing
|
||||
RDEpubReaderTapDebug.log(
|
||||
"PreloadController.prime",
|
||||
"cache HIT(preloaded) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(existing))"
|
||||
)
|
||||
} else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil {
|
||||
contentView = cached
|
||||
RDEpubReaderTapDebug.log(
|
||||
"PreloadController.prime",
|
||||
"cache HIT(pageCurl) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(cached))"
|
||||
)
|
||||
} else {
|
||||
contentView = contentViewProvider(targetPage, nil) ?? UIView()
|
||||
RDEpubReaderTapDebug.log(
|
||||
"PreloadController.prime",
|
||||
"cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
|
||||
)
|
||||
}
|
||||
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
||||
if shouldCacheContentView {
|
||||
preloadedPageViews[targetPage] = contentView
|
||||
} else {
|
||||
preloadedPageViews.removeValue(forKey: targetPage)
|
||||
}
|
||||
if contentView.superview !== preloadHostView {
|
||||
contentView.removeFromSuperview()
|
||||
preloadHostView.addSubview(contentView)
|
||||
}
|
||||
contentView.frame = preloadHostView.bounds
|
||||
if !shouldCacheContentView {
|
||||
contentView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 != RDEpubReaderView.blankPageNum
|
||||
&& pageNum != RDEpubReaderView.blankEndPageNum
|
||||
&& pageNum < environment.totalPages
|
||||
}
|
||||
|
||||
private func shouldCache(view: UIView, for pageNum: Int, environment: Environment) -> Bool {
|
||||
guard shouldCachePage(pageNum, environment: environment) else { return false }
|
||||
if let policyProvider = view as? RDEpubReaderCachePolicyProviding {
|
||||
return !policyProvider.shouldAvoidReaderPageCaching
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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 {
|
||||
discard(value)
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
preloadedPageViews = preloadedPageViews.filter { key, value in
|
||||
let keep = pageNumbers.contains(key)
|
||||
if !keep {
|
||||
discard(value)
|
||||
}
|
||||
return keep
|
||||
}
|
||||
}
|
||||
|
||||
private func discard(_ view: UIView) {
|
||||
// 只有闲置视图(未挂载或还在预加载宿主里)才能立即拆除内容;
|
||||
// 挂在子 VC / cell 上的视图可能正在展示或会被 UIPageViewController
|
||||
// 的缓存重新呈现,提前拆除会让翻页露出空白页。这类视图交由持有方
|
||||
// 释放后经 deinit 自然回收(弱代理已保证无保留环)。
|
||||
let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView
|
||||
view.removeFromSuperview()
|
||||
if !isHeldOutsidePreloadHost {
|
||||
(view as? RDEpubReaderPageResourceReleasing)?.releaseResources()
|
||||
}
|
||||
}
|
||||
|
||||
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,74 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
struct RDEpubReaderSpreadResolver {
|
||||
|
||||
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,29 @@
|
||||
|
||||
import CoreGraphics
|
||||
|
||||
struct RDEpubReaderTapRegionHandler {
|
||||
|
||||
func resolveTapEvent(
|
||||
point: CGPoint,
|
||||
viewFrame: CGRect,
|
||||
isToolViewVisible: Bool
|
||||
) -> RDEpubReaderView.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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user