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:
shenlei
2026-07-10 19:44:53 +09:00
parent d5a7755702
commit d7fcda345d
460 changed files with 38358 additions and 2300 deletions
@@ -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
}
}