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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
class RDEpubReaderContentCell: UICollectionViewCell {
|
||||
|
||||
private var _containerView: UIView? = nil
|
||||
|
||||
var containerView: UIView? {
|
||||
set {
|
||||
guard newValue !== _containerView else { return }
|
||||
_containerView?.removeFromSuperview()
|
||||
_containerView = newValue
|
||||
if let containerView = _containerView {
|
||||
contentView.addSubview(containerView)
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
get {
|
||||
return _containerView
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
_containerView?.frame = CGRect(x: 0, y: 0, width: contentView.frame.width, height: contentView.frame.height)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.content.cell"
|
||||
}
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
public protocol RDEpubReaderFlowLayoutDataSoure: NSObjectProtocol {
|
||||
|
||||
func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat?
|
||||
}
|
||||
|
||||
@objc public protocol RDEpubReaderFlowLayoutDelegate: NSObjectProtocol {
|
||||
|
||||
func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int)
|
||||
}
|
||||
|
||||
public class RDEpubReaderFlowLayout: UICollectionViewFlowLayout {
|
||||
|
||||
var displayType: RDEpubReaderView.DisplayType = .horizontalScroll {
|
||||
didSet {
|
||||
invalidateLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var isLandscapeDualPage: Bool = false {
|
||||
didSet {
|
||||
if oldValue != isLandscapeDualPage {
|
||||
invalidateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var coverPageIndex: Int? = nil {
|
||||
didSet {
|
||||
if oldValue != coverPageIndex {
|
||||
invalidateLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var lastPreparedBoundsSize: CGSize = .zero
|
||||
|
||||
var pagesPerScreen: Int {
|
||||
guard isLandscapeDualPage else { return 1 }
|
||||
switch displayType {
|
||||
case .horizontalScroll:
|
||||
if let cv = collectionView, cv.bounds.width > cv.bounds.height {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
weak var dataSource: RDEpubReaderFlowLayoutDataSoure? = nil
|
||||
|
||||
weak var delegate: RDEpubReaderFlowLayoutDelegate? = nil
|
||||
|
||||
private var hasCoverPageInDualMode: Bool {
|
||||
return pagesPerScreen > 1 && coverPageIndex != nil
|
||||
}
|
||||
|
||||
private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect {
|
||||
guard let coverIndex = coverPageIndex else {
|
||||
let pairIdx = index / 2
|
||||
let side = index % 2
|
||||
let x = CGFloat(pairIdx) * screenWidth + CGFloat(side) * halfWidth
|
||||
return CGRect(x: x, y: 0, width: halfWidth, height: height)
|
||||
}
|
||||
if index == coverIndex {
|
||||
let screensBeforeCover = coverIndex / 2
|
||||
let x = CGFloat(screensBeforeCover) * screenWidth
|
||||
return CGRect(x: x, y: 0, width: screenWidth, height: height)
|
||||
}
|
||||
let adjustedIndex = index - (coverIndex + 1)
|
||||
let pairIdx = adjustedIndex / 2
|
||||
let side = adjustedIndex % 2
|
||||
let coverScreens = coverIndex / 2 + 1
|
||||
let x = CGFloat(coverScreens + pairIdx) * screenWidth + CGFloat(side) * halfWidth
|
||||
return CGRect(x: x, y: 0, width: halfWidth, height: height)
|
||||
}
|
||||
|
||||
private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int {
|
||||
let pps = pagesPerScreen
|
||||
guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else {
|
||||
let screenIndex = Int((offset / screenWidth).rounded(.down))
|
||||
return screenIndex * pps
|
||||
}
|
||||
let coverScreens = coverIdx / 2 + 1
|
||||
let coverEnd = CGFloat(coverScreens) * screenWidth
|
||||
if offset < coverEnd {
|
||||
let screenIdx = Int((offset / screenWidth).rounded(.down))
|
||||
return screenIdx >= coverIdx / 2 ? coverIdx : screenIdx * pps
|
||||
} else {
|
||||
let pairIdx = Int(((offset - coverEnd) / screenWidth).rounded(.down))
|
||||
return coverIdx + 1 + pairIdx * 2
|
||||
}
|
||||
}
|
||||
|
||||
private var currentPage: Int = 0 {
|
||||
|
||||
didSet {
|
||||
if let delegate = delegate, delegate.responds(to: #selector(RDEpubReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 {
|
||||
delegate.pageNum(flowLayout: self, pageIndex: currentPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(displayType: RDEpubReaderView.DisplayType) {
|
||||
self.displayType = displayType
|
||||
super.init()
|
||||
|
||||
}
|
||||
|
||||
public override func prepare() {
|
||||
super.prepare()
|
||||
guard let collectionView = self.collectionView else {
|
||||
return
|
||||
}
|
||||
guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
lastPreparedBoundsSize = collectionView.bounds.size
|
||||
|
||||
if #available(iOS 11.0, *) {
|
||||
collectionView.contentInsetAdjustmentBehavior = .never
|
||||
} else {
|
||||
collectionView.ss_superViewController?.automaticallyAdjustsScrollViewInsets = false
|
||||
}
|
||||
collectionView.showsVerticalScrollIndicator = false
|
||||
collectionView.showsHorizontalScrollIndicator = false
|
||||
minimumLineSpacing = 0
|
||||
minimumInteritemSpacing = 0
|
||||
switch self.displayType {
|
||||
case .horizontalScroll:
|
||||
scrollDirection = .horizontal
|
||||
let columns = CGFloat(pagesPerScreen)
|
||||
itemSize = CGSize(width: collectionView.frame.width / columns, height: collectionView.frame.height)
|
||||
collectionView.isPagingEnabled = true
|
||||
case .verticalScroll:
|
||||
itemSize = CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
|
||||
scrollDirection = .vertical
|
||||
collectionView.isPagingEnabled = false
|
||||
collectionView.showsVerticalScrollIndicator = true
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
||||
if newBounds.size != lastPreparedBoundsSize {
|
||||
return true
|
||||
}
|
||||
switch displayType {
|
||||
case .verticalScroll:
|
||||
return true
|
||||
case .horizontalScroll:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public override var collectionViewContentSize: CGSize {
|
||||
var size = super.collectionViewContentSize
|
||||
guard let collectionView = collectionView else {
|
||||
return size
|
||||
}
|
||||
if displayType == .verticalScroll {
|
||||
let totalHeight = [Int](0..<collectionView.numberOfItems(inSection: 0)).reduce(0.0, { result, num in
|
||||
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: num) ?? collectionView.frame.height)
|
||||
})
|
||||
size.height = totalHeight
|
||||
}
|
||||
if hasCoverPageInDualMode, displayType == .horizontalScroll {
|
||||
let totalItems = collectionView.numberOfItems(inSection: 0)
|
||||
let screenWidth = collectionView.frame.width
|
||||
let remainingItems = max(0, totalItems - 1)
|
||||
let pairedScreens = (remainingItems + 1) / 2
|
||||
size.width = screenWidth * CGFloat(1 + pairedScreens)
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
var attributes = super.layoutAttributesForElements(in: rect)
|
||||
guard let collectionView = collectionView else {
|
||||
return attributes
|
||||
}
|
||||
|
||||
if self.displayType == .verticalScroll {
|
||||
let rowCount = collectionView.numberOfItems(inSection: 0)
|
||||
var totalHeight: CGFloat = 0
|
||||
for index in 0..<rowCount {
|
||||
totalHeight += dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: index) ?? collectionView.frame.height
|
||||
if totalHeight > collectionView.contentOffset.y {
|
||||
self.currentPage = index
|
||||
break
|
||||
}
|
||||
}
|
||||
(attributes ?? []).forEach({ attr in
|
||||
let indexPath = attr.indexPath
|
||||
let lastRow = max(0, indexPath.row - 1)
|
||||
let height = dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: indexPath.row) ?? collectionView.frame.height
|
||||
var lastTotalHeight: CGFloat = 0
|
||||
if indexPath.row > 0 {
|
||||
lastTotalHeight = [Int](0...lastRow).reduce(0.0) { result, row in
|
||||
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: row) ?? collectionView.frame.height)
|
||||
}
|
||||
}
|
||||
if let cell = collectionView.cellForItem(at: indexPath) {
|
||||
cell.layer.shadowOpacity = 0
|
||||
}
|
||||
attr.frame = CGRect(x: 0, y: lastTotalHeight, width: collectionView.frame.width, height: height)
|
||||
})
|
||||
}
|
||||
|
||||
if self.displayType == .horizontalScroll {
|
||||
let pps = pagesPerScreen
|
||||
|
||||
if hasCoverPageInDualMode {
|
||||
let screenWidth = collectionView.frame.width
|
||||
let halfWidth = screenWidth / 2.0
|
||||
let rows = collectionView.numberOfItems(inSection: 0)
|
||||
|
||||
self.currentPage = coverAwareStartIndex(for: collectionView.contentOffset.x, screenWidth: screenWidth)
|
||||
|
||||
var attrs = [UICollectionViewLayoutAttributes]()
|
||||
for index in 0..<rows {
|
||||
let frame = coverAwareFrame(for: index, screenWidth: screenWidth, halfWidth: halfWidth, height: collectionView.frame.height)
|
||||
guard frame.intersects(rect) else { continue }
|
||||
let indexPath = IndexPath(item: index, section: 0)
|
||||
let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
|
||||
attr.frame = frame
|
||||
attrs.append(attr)
|
||||
if let cell = collectionView.cellForItem(at: indexPath) {
|
||||
cell.layer.shadowOpacity = 0
|
||||
}
|
||||
}
|
||||
attributes = attrs
|
||||
} else if pps > 1 {
|
||||
let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down)) * pps
|
||||
self.currentPage = currentPage
|
||||
(attributes ?? []).forEach({ attr in
|
||||
if let cell = collectionView.cellForItem(at: attr.indexPath) {
|
||||
cell.layer.shadowOpacity = 0
|
||||
}
|
||||
})
|
||||
} else {
|
||||
let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down))
|
||||
self.currentPage = currentPage
|
||||
(attributes ?? []).forEach({ attr in
|
||||
if let cell = collectionView.cellForItem(at: attr.indexPath) {
|
||||
cell.layer.shadowOpacity = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
|
||||
func currentContentOffset(count: Int) -> CGPoint {
|
||||
guard let collectionView = collectionView else {
|
||||
return .zero
|
||||
}
|
||||
guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
|
||||
return .zero
|
||||
}
|
||||
let safeCount = max(0, count)
|
||||
switch displayType {
|
||||
case .verticalScroll:
|
||||
guard safeCount > 0 else { return .zero }
|
||||
let totalHeight = [Int](0...(safeCount - 1)).reduce(0.0) { result, pageNum in
|
||||
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: pageNum) ?? collectionView.frame.height)
|
||||
}
|
||||
return CGPoint(x: 0, y: totalHeight)
|
||||
default:
|
||||
let pps = pagesPerScreen
|
||||
let screenWidth = collectionView.frame.width
|
||||
if hasCoverPageInDualMode, let coverIdx = coverPageIndex {
|
||||
if safeCount == coverIdx {
|
||||
let screensBeforeCover = coverIdx / 2
|
||||
return CGPoint(x: CGFloat(screensBeforeCover) * screenWidth, y: 0)
|
||||
}
|
||||
if safeCount < coverIdx {
|
||||
return CGPoint(x: CGFloat(safeCount / 2) * screenWidth, y: 0)
|
||||
}
|
||||
let coverScreens = coverIdx / 2 + 1
|
||||
let adjustedIndex = safeCount - (coverIdx + 1)
|
||||
let pairIndex = adjustedIndex / 2
|
||||
return CGPoint(x: CGFloat(coverScreens + pairIndex) * screenWidth, y: 0)
|
||||
} else if pps > 1 {
|
||||
let screenIndex = safeCount / pps
|
||||
return CGPoint(x: CGFloat(screenIndex) * screenWidth, y: 0)
|
||||
} else {
|
||||
return CGPoint(x: CGFloat(safeCount) * itemSize.width, y: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
class RDEpubReaderGestureController: UIViewController {
|
||||
|
||||
var topToolView: UIView?
|
||||
|
||||
var bottomToolView: UIView?
|
||||
|
||||
init(topToolView: UIView?, bottomToolView: UIView?) {
|
||||
self.topToolView = topToolView
|
||||
self.bottomToolView = bottomToolView
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
class RDEpubReaderPageChildViewController: UIViewController {
|
||||
|
||||
private let contentContainerView = UIView()
|
||||
|
||||
var contentView: UIView? {
|
||||
didSet {
|
||||
guard isViewLoaded else { return }
|
||||
installContentView()
|
||||
}
|
||||
}
|
||||
|
||||
var pageNum: Int = 0
|
||||
|
||||
init(contentView: UIView?, pageNum: Int = 0) {
|
||||
self.contentView = contentView
|
||||
self.pageNum = pageNum
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
override func loadView() {
|
||||
view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
contentContainerView.frame = view.bounds
|
||||
contentContainerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentContainerView.backgroundColor = .clear
|
||||
view.addSubview(contentContainerView)
|
||||
installContentView()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
}
|
||||
|
||||
private func installContentView() {
|
||||
contentContainerView.subviews.forEach { $0.removeFromSuperview() }
|
||||
guard let contentView else { return }
|
||||
contentView.removeFromSuperview()
|
||||
contentView.frame = contentContainerView.bounds
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentContainerView.addSubview(contentView)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDelegate, RDEpubReaderFlowLayoutDataSoure {
|
||||
|
||||
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! RDEpubReaderContentCell
|
||||
let preloadedView = preloadController.takePreloadedView(for: indexPath.row)
|
||||
let reusableView = cell.containerView ?? preloadedView
|
||||
cell.containerView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return numberOfPages()
|
||||
}
|
||||
|
||||
public func pageNum(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) {
|
||||
if currentPage >= 0, currentPage != pageIndex {
|
||||
predictedPageDirection = pageIndex >= currentPage
|
||||
}
|
||||
currentPage = pageIndex
|
||||
primePageCache(around: pageIndex, preferredForward: predictedPageDirection)
|
||||
}
|
||||
|
||||
public func heigtOfVerticalScrollPage(flowLayout: RDEpubReaderFlowLayout, pageIndex: Int) -> CGFloat? {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension RDEpubReaderView {
|
||||
|
||||
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
|
||||
contentViews[identifier] = contentView
|
||||
collectionView.register(RDEpubReaderContentCell.self, forCellWithReuseIdentifier: identifier)
|
||||
}
|
||||
|
||||
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? RDEpubReaderContentCell, let containerView = cell.containerView {
|
||||
return containerView
|
||||
}
|
||||
let contentViewClass = contentViews[identifier]
|
||||
assert(contentViewClass != nil, "请调用register(contentView:contentViewWithReuseIdentifier:)")
|
||||
let contentView = contentViewClass!.init()
|
||||
return contentView
|
||||
}
|
||||
|
||||
public func pageContentView(pageNum: Int) -> UIView? {
|
||||
if currentDisplayType == .pageCurl {
|
||||
return (self.pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.contentView
|
||||
} else {
|
||||
let cell = collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDEpubReaderContentCell
|
||||
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? RDEpubReaderPageChildViewController,
|
||||
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,123 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
||||
|
||||
private func makeSinglePageChildVC(for pageNum: Int) -> RDEpubReaderPageChildViewController {
|
||||
if pageNum == RDEpubReaderView.blankPageNum || pageNum == RDEpubReaderView.blankEndPageNum {
|
||||
return RDEpubReaderPageChildViewController(contentView: UIView(), pageNum: pageNum)
|
||||
}
|
||||
let contentView = pageViewForDisplay(pageNum: pageNum)
|
||||
return RDEpubReaderPageChildViewController(contentView: contentView, pageNum: pageNum)
|
||||
}
|
||||
|
||||
private func nextPageNum(after pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
let totalPages = numberOfPages()
|
||||
|
||||
if pageNum == RDEpubReaderView.blankEndPageNum {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isDualPage, let coverIndex = coverPageIndex {
|
||||
if pageNum == coverIndex {
|
||||
return RDEpubReaderView.blankPageNum
|
||||
}
|
||||
if pageNum == RDEpubReaderView.blankPageNum {
|
||||
let firstContent = coverIndex + 1
|
||||
return firstContent < totalPages ? firstContent : nil
|
||||
}
|
||||
}
|
||||
|
||||
let next = pageNum + 1
|
||||
if next < totalPages {
|
||||
return next
|
||||
}
|
||||
|
||||
if isDualPage {
|
||||
if let coverIndex = coverPageIndex {
|
||||
let adjustedIndex = pageNum - (coverIndex + 1)
|
||||
if adjustedIndex >= 0 && adjustedIndex % 2 == 0 {
|
||||
return RDEpubReaderView.blankEndPageNum
|
||||
}
|
||||
} else {
|
||||
if pageNum % 2 == 0 {
|
||||
return RDEpubReaderView.blankEndPageNum
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func prevPageNum(before pageNum: Int, isDualPage: Bool) -> Int? {
|
||||
if pageNum == RDEpubReaderView.blankEndPageNum {
|
||||
let totalPages = numberOfPages()
|
||||
return totalPages > 0 ? totalPages - 1 : nil
|
||||
}
|
||||
if isDualPage, let coverIndex = coverPageIndex {
|
||||
if pageNum == RDEpubReaderView.blankPageNum {
|
||||
return coverIndex
|
||||
}
|
||||
if pageNum == coverIndex + 1 {
|
||||
return RDEpubReaderView.blankPageNum
|
||||
}
|
||||
}
|
||||
let prev = pageNum - 1
|
||||
return prev >= 0 ? prev : nil
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let vc = viewController as? RDEpubReaderPageChildViewController 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
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||
guard let vc = viewController as? RDEpubReaderPageChildViewController 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
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
let resolvedPage = (pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.pageNum ?? -1
|
||||
if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController {
|
||||
let pn = firstVC.pageNum
|
||||
if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum {
|
||||
currentPage = pn
|
||||
primePageCache(around: pn, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
|
||||
predictedPageDirection = nil
|
||||
finishPageCurlTransition()
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
pagingController.isTransitioning = true
|
||||
willTransitionToViewController = pendingViewControllers.first
|
||||
if let target = pendingViewControllers.first as? RDEpubReaderPageChildViewController,
|
||||
target.pageNum != RDEpubReaderView.blankPageNum,
|
||||
target.pageNum != RDEpubReaderView.blankEndPageNum {
|
||||
predictedPageDirection = target.pageNum >= currentPage
|
||||
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension RDEpubReaderView {
|
||||
|
||||
func tapCenter() {
|
||||
refreshToolViewsFromProviderIfNeeded()
|
||||
isShowToolView = !isShowToolView
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.tapCenter",
|
||||
"toggle toolView visible=\(isShowToolView) top=\(RDEpubReaderTapDebug.describe(topToolView)) bottom=\(RDEpubReaderTapDebug.describe(bottomToolView))"
|
||||
)
|
||||
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
|
||||
RDEpubReaderTapDebug.log("ReaderView.tapCenter", "disabled collectionView/pageViewController interaction while toolView is visible")
|
||||
} 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
|
||||
RDEpubReaderTapDebug.log("ReaderView.tapCenter", "re-enabled collectionView/pageViewController interaction after hiding toolView")
|
||||
}
|
||||
onToolViewVisibilityChanged?(isShowToolView)
|
||||
}
|
||||
|
||||
func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
|
||||
if let hitView, hitView === toolView || hitView.isDescendant(of: toolView) {
|
||||
return true
|
||||
}
|
||||
|
||||
if toolView.frame.contains(point) {
|
||||
// For search bar views, only consider the hit inside the visible
|
||||
// panel area. Taps on the dimmed scrim should pass through to
|
||||
// the reader content to allow chrome toggling. Touches that
|
||||
// resolved to a search bar subview (e.g. the accessibility-only
|
||||
// navigation buttons outside the panel) are already claimed by
|
||||
// the descendant check above.
|
||||
if let searchBarView = toolView as? RDEPUBReaderSearchBarView {
|
||||
let panelPoint = convert(point, to: searchBarView.panelView)
|
||||
if !searchBarView.panelView.point(inside: panelPoint, with: nil) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,961 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
enum RDEpubReaderTapDebug {
|
||||
|
||||
private static let enabledArguments: Set<String> = [
|
||||
"--demo-tap-debug",
|
||||
"--demo-reader-interaction-debug"
|
||||
]
|
||||
|
||||
static var isEnabled: Bool {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
if arguments.contains(where: { enabledArguments.contains($0) }) {
|
||||
return true
|
||||
}
|
||||
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
return environment["RDEpubReader_TAP_DEBUG"] == "1"
|
||||
#endif
|
||||
}
|
||||
|
||||
static func log(_ scope: String, _ message: String) {
|
||||
guard isEnabled else { return }
|
||||
let threadRole = Thread.isMainThread ? "main" : "bg"
|
||||
let queueLabel = String(validatingUTF8: __dispatch_queue_get_label(nil)) ?? "unknown"
|
||||
print("[RDEpubReaderTap][\(scope)][\(threadRole)][queue=\(queueLabel)] \(message)")
|
||||
}
|
||||
|
||||
static func describe(_ point: CGPoint) -> String {
|
||||
"(\(format(point.x)), \(format(point.y)))"
|
||||
}
|
||||
|
||||
static func describe(_ rect: CGRect) -> String {
|
||||
"(x:\(format(rect.origin.x)), y:\(format(rect.origin.y)), w:\(format(rect.size.width)), h:\(format(rect.size.height)))"
|
||||
}
|
||||
|
||||
static func describe(_ view: UIView?) -> String {
|
||||
guard let view else { return "nil" }
|
||||
let identifier = view.accessibilityIdentifier ?? "nil"
|
||||
return "\(type(of: view))(addr=\(Unmanaged.passUnretained(view).toOpaque()), id=\(identifier))"
|
||||
}
|
||||
|
||||
private static func format(_ value: CGFloat) -> String {
|
||||
String(format: "%.1f", value)
|
||||
}
|
||||
}
|
||||
|
||||
public class RDEpubReaderView: UIView {
|
||||
|
||||
enum TapEvent {
|
||||
|
||||
case none
|
||||
|
||||
case left
|
||||
|
||||
case center
|
||||
|
||||
case right
|
||||
}
|
||||
|
||||
lazy var pageViewController: UIPageViewController = {
|
||||
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
|
||||
pageVC.delegate = self
|
||||
pageVC.dataSource = self
|
||||
return pageVC
|
||||
}()
|
||||
|
||||
lazy var layout: RDEpubReaderFlowLayout = {
|
||||
let layout = RDEpubReaderFlowLayout(displayType: .horizontalScroll)
|
||||
layout.dataSource = self
|
||||
layout.delegate = self
|
||||
return layout
|
||||
}()
|
||||
|
||||
lazy var collectionView: UICollectionView = {
|
||||
|
||||
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
collectionView.backgroundColor = UIColor.clear
|
||||
collectionView.accessibilityIdentifier = "epub.reader.paging"
|
||||
return collectionView
|
||||
}()
|
||||
|
||||
private let spreadResolver = RDEpubReaderSpreadResolver()
|
||||
|
||||
private let tapRegionHandler = RDEpubReaderTapRegionHandler()
|
||||
|
||||
let preloadController = RDEpubReaderPreloadController()
|
||||
|
||||
var pagingController = RDEpubReaderPagingController()
|
||||
|
||||
public var currentPage: Int = -1 {
|
||||
didSet {
|
||||
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDEpubReaderDelegate.pageNum(readerView:pageNum:))) {
|
||||
delegate.pageNum(readerView: self, pageNum: currentPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private(set) lazy var tapGestureRecognizer: UITapGestureRecognizer = {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction(tap:)))
|
||||
tap.delegate = self
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func goNextPage() {
|
||||
let totalPages = numberOfPages()
|
||||
if let target = spreadResolver.nextPage(
|
||||
from: currentPage,
|
||||
totalPages: totalPages,
|
||||
pagesPerScreen: pagesPerScreen,
|
||||
coverPageIndex: coverPageIndex,
|
||||
forward: true
|
||||
) {
|
||||
transitionToPage(pageNum: target, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func goPreviousPage() {
|
||||
let totalPages = numberOfPages()
|
||||
if let target = spreadResolver.nextPage(
|
||||
from: currentPage,
|
||||
totalPages: totalPages,
|
||||
pagesPerScreen: pagesPerScreen,
|
||||
coverPageIndex: coverPageIndex,
|
||||
forward: false
|
||||
) {
|
||||
transitionToPage(pageNum: target, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private let legacyDataSourceAdapter = RDEpubReaderLegacyDataSourceAdapter()
|
||||
|
||||
public weak var dataSource: RDEpubReaderDataSource? {
|
||||
didSet {
|
||||
legacyDataSourceAdapter.dataSource = dataSource
|
||||
}
|
||||
}
|
||||
|
||||
public weak var pageProvider: RDEpubReaderPageProvider?
|
||||
|
||||
public weak var delegate: RDEpubReaderDelegate? = nil
|
||||
|
||||
public var currentDisplayType: RDEpubReaderView.DisplayType = .pageCurl
|
||||
|
||||
public var toolViewAnimationDuration: TimeInterval = 0.3
|
||||
|
||||
var onToolViewVisibilityChanged: ((Bool) -> Void)?
|
||||
|
||||
var searchBarView: UIView?
|
||||
|
||||
public var landscapeDualPageEnabled: Bool = false
|
||||
|
||||
public var pageDirection: RDEpubReaderView.PageDirection = .leftToRight
|
||||
|
||||
public var coverPageIndex: Int? = nil
|
||||
|
||||
private var resolvedPageProvider: RDEpubReaderPageProvider? {
|
||||
pageProvider ?? legacyDataSourceAdapter
|
||||
}
|
||||
|
||||
func numberOfPages() -> Int {
|
||||
resolvedPageProvider?.numberOfPages(in: self) ?? 0
|
||||
}
|
||||
|
||||
func pageReuseIdentifier(for pageNum: Int) -> String? {
|
||||
resolvedPageProvider?.pageIdentifier?(in: self, index: pageNum)
|
||||
}
|
||||
|
||||
func contentViewForPage(_ pageNum: Int, reusableView: UIView?) -> UIView {
|
||||
resolvedPageProvider?.readerView(self, viewForPageAt: pageNum, reusableView: reusableView) ?? UIView()
|
||||
}
|
||||
|
||||
private func resolvedTopChromeView() -> UIView? {
|
||||
resolvedPageProvider?.readerViewTopChrome?(self)
|
||||
}
|
||||
|
||||
private func resolvedBottomChromeView() -> UIView? {
|
||||
resolvedPageProvider?.readerViewBottomChrome?(self)
|
||||
}
|
||||
|
||||
func refreshToolViewsFromProviderIfNeeded() {
|
||||
if topToolView == nil {
|
||||
topToolView = resolvedTopChromeView()
|
||||
}
|
||||
if bottomToolView == nil {
|
||||
bottomToolView = resolvedBottomChromeView()
|
||||
}
|
||||
}
|
||||
|
||||
private var hasCoverPage: Bool {
|
||||
return coverPageIndex != nil
|
||||
}
|
||||
|
||||
var isLandscape: Bool {
|
||||
return bounds.width > bounds.height
|
||||
}
|
||||
|
||||
public var pagesPerScreen: Int {
|
||||
if !landscapeDualPageEnabled { return 1 }
|
||||
if currentDisplayType == .verticalScroll { return 1 }
|
||||
return isLandscape ? 2 : 1
|
||||
}
|
||||
|
||||
public func isFullScreenPage(_ pageNum: Int) -> Bool {
|
||||
spreadResolver.isFullScreenPage(
|
||||
pageNum,
|
||||
landscapeDualPageEnabled: landscapeDualPageEnabled,
|
||||
isLandscape: isLandscape,
|
||||
coverPageIndex: coverPageIndex
|
||||
)
|
||||
}
|
||||
|
||||
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
|
||||
let totalPages = numberOfPages()
|
||||
return spreadResolver.dualPagePair(
|
||||
for: pageNum,
|
||||
totalPages: totalPages,
|
||||
coverPageIndex: coverPageIndex
|
||||
)
|
||||
}
|
||||
|
||||
private func adjacentDualPage(from pageNum: Int, forward: Bool) -> Int? {
|
||||
let totalPages = numberOfPages()
|
||||
return spreadResolver.adjacentDualPage(
|
||||
from: pageNum,
|
||||
totalPages: totalPages,
|
||||
coverPageIndex: coverPageIndex,
|
||||
forward: forward
|
||||
)
|
||||
}
|
||||
|
||||
private func clampedPageNumber(_ pageNum: Int) -> Int? {
|
||||
let totalPages = numberOfPages()
|
||||
guard totalPages > 0 else { return nil }
|
||||
return min(max(pageNum, 0), totalPages - 1)
|
||||
}
|
||||
|
||||
private var previousIsLandscape: Bool?
|
||||
|
||||
private var pendingIsLandscape: Bool?
|
||||
|
||||
private var isWaitingForOrientationTransitionCompletion = false
|
||||
|
||||
var contentViews = [String : UIView.Type]()
|
||||
|
||||
var willPreviousTransitionToViewController: UIViewController? = nil
|
||||
|
||||
var willNextTransitionToViewController: UIViewController? = nil
|
||||
|
||||
var willTransitionToViewController: UIViewController? = nil
|
||||
|
||||
var topToolView: UIView?
|
||||
|
||||
var bottomToolView: UIView?
|
||||
|
||||
var topToolViewHeightConstraint: NSLayoutConstraint?
|
||||
|
||||
var bottomToolViewHeightConstraint: NSLayoutConstraint?
|
||||
|
||||
var isShowToolView: Bool = false
|
||||
|
||||
private var isTransitioning: Bool {
|
||||
get { pagingController.isTransitioning }
|
||||
set { pagingController.isTransitioning = newValue }
|
||||
}
|
||||
|
||||
private var didBuildUI: Bool {
|
||||
get { pagingController.didBuildUI }
|
||||
set { pagingController.didBuildUI = newValue }
|
||||
}
|
||||
|
||||
var predictedPageDirection: Bool?
|
||||
|
||||
public var preloadRadius: Int {
|
||||
get { preloadController.radius }
|
||||
set { preloadController.radius = newValue }
|
||||
}
|
||||
|
||||
static let blankPageNum = Int.max
|
||||
|
||||
static let blankEndPageNum = Int.max - 1
|
||||
|
||||
private typealias PageTransitionRequest = RDEpubReaderPagingController.PageTransitionRequest
|
||||
|
||||
private let registeredSelectionLongPressRecognizers = NSHashTable<UILongPressGestureRecognizer>.weakObjects()
|
||||
|
||||
private let selectionTapSuppressedContentViews = NSHashTable<UIView>.weakObjects()
|
||||
|
||||
private let selectionPagingSuppressedContentViews = NSHashTable<UIView>.weakObjects()
|
||||
|
||||
private var isPagingInteractionSuppressed = false
|
||||
|
||||
private var pendingTransitionRequest: PageTransitionRequest? {
|
||||
get { pagingController.pendingTransitionRequest }
|
||||
set { pagingController.pendingTransitionRequest = newValue }
|
||||
}
|
||||
|
||||
var isPageCurlTransitioning: Bool {
|
||||
currentDisplayType == .pageCurl && pagingController.isTransitioning
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.content"
|
||||
isAccessibilityElement = false
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
preloadController.setHostFrame(bounds)
|
||||
updateToolViewHeightConstraintsIfNeeded()
|
||||
let nowLandscape = isLandscape
|
||||
if let prev = previousIsLandscape, prev != nowLandscape {
|
||||
previousIsLandscape = nowLandscape
|
||||
scheduleOrientationChange(isNowLandscape: nowLandscape)
|
||||
} else if previousIsLandscape == nil {
|
||||
previousIsLandscape = nowLandscape
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleOrientationChange(isNowLandscape: Bool) {
|
||||
pendingIsLandscape = isNowLandscape
|
||||
|
||||
// UIPageViewController asserts if it is detached/rebuilt while UIKit is
|
||||
// still executing its own rotation callbacks. Rebuild the single/dual
|
||||
// page controller only after the enclosing transition has completed.
|
||||
if let coordinator = ss_superViewController?.transitionCoordinator,
|
||||
coordinator.isAnimated {
|
||||
guard !isWaitingForOrientationTransitionCompletion else { return }
|
||||
isWaitingForOrientationTransitionCompletion = true
|
||||
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.isWaitingForOrientationTransitionCompletion = false
|
||||
self.applyPendingOrientationChange()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.applyPendingOrientationChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPendingOrientationChange() {
|
||||
guard let isNowLandscape = pendingIsLandscape else { return }
|
||||
pendingIsLandscape = nil
|
||||
orientationChanged(isNowLandscape: isNowLandscape)
|
||||
}
|
||||
|
||||
private func orientationChanged(isNowLandscape: Bool) {
|
||||
let savedPage = max(0, currentPage)
|
||||
|
||||
delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape)
|
||||
|
||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
|
||||
layout.coverPageIndex = coverPageIndex
|
||||
|
||||
switch currentDisplayType {
|
||||
case .pageCurl:
|
||||
|
||||
rebuildPageViewController()
|
||||
transitionToPage(pageNum: savedPage)
|
||||
primePageCache(around: savedPage, preferredForward: predictedPageDirection)
|
||||
default:
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
|
||||
let totalPages = numberOfPages()
|
||||
let safePage = min(savedPage, max(0, totalPages - 1))
|
||||
let targetOffset = layout.currentContentOffset(count: safePage)
|
||||
collectionView.setContentOffset(targetOffset, animated: false)
|
||||
primePageCache(around: safePage, preferredForward: predictedPageDirection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func rebuildPageViewController() {
|
||||
detachPageViewControllerIfNeeded()
|
||||
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
let pageVC = createPageViewController(isDualPage: isDualPage)
|
||||
pageViewController = pageVC
|
||||
attachPageViewControllerIfNeeded()
|
||||
}
|
||||
|
||||
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? RDEpubReaderPageChildViewController }
|
||||
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) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum)
|
||||
return childViewControllers[0].pageNum != expectedPair.left
|
||||
|| childViewControllers[1].pageNum != expectedRightPage
|
||||
}
|
||||
|
||||
private func patchPageViewControllerFault() {
|
||||
guard currentPage >= 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pagingController.resetPendingState()
|
||||
self.willPreviousTransitionToViewController = nil
|
||||
self.willNextTransitionToViewController = nil
|
||||
self.willTransitionToViewController = nil
|
||||
self.invalidatePageCaches()
|
||||
self.rebuildPageViewController()
|
||||
self.transitionToPage(pageNum: self.currentPage, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool {
|
||||
pagingController.shouldQueuePageTransition(request, currentDisplayType: currentDisplayType)
|
||||
}
|
||||
|
||||
func finishPageCurlTransition(repairFaultIfNeeded: Bool = true) {
|
||||
willPreviousTransitionToViewController = nil
|
||||
willNextTransitionToViewController = nil
|
||||
willTransitionToViewController = nil
|
||||
|
||||
if repairFaultIfNeeded, detectPageViewControllerFault(pageViewController) {
|
||||
patchPageViewControllerFault()
|
||||
return
|
||||
}
|
||||
|
||||
if let pending = pagingController.finishPageCurlTransition() {
|
||||
transitionToPage(pageNum: pending.pageNum, animated: pending.animated)
|
||||
}
|
||||
|
||||
updatePagingInteractionSuppression()
|
||||
}
|
||||
|
||||
private var preloadEnvironment: RDEpubReaderPreloadController.Environment {
|
||||
RDEpubReaderPreloadController.Environment(
|
||||
displayType: currentDisplayType,
|
||||
isLandscape: isLandscape,
|
||||
pagesPerScreen: pagesPerScreen,
|
||||
boundsSize: bounds.size,
|
||||
landscapeDualPageEnabled: landscapeDualPageEnabled,
|
||||
coverPageIndex: coverPageIndex,
|
||||
totalPages: numberOfPages(),
|
||||
spreadResolver: spreadResolver
|
||||
)
|
||||
}
|
||||
|
||||
private func invalidatePageCaches() {
|
||||
preloadController.invalidate(environment: preloadEnvironment)
|
||||
}
|
||||
|
||||
func pageViewForDisplay(pageNum: Int) -> UIView {
|
||||
preloadController.pageViewForDisplay(
|
||||
pageNum: pageNum,
|
||||
environment: preloadEnvironment,
|
||||
contentViewProvider: pageContentViewForPreload(pageNum:reusableView:)
|
||||
)
|
||||
}
|
||||
|
||||
func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
|
||||
preloadController.prime(
|
||||
around: pageNum,
|
||||
preferredForward: preferredForward,
|
||||
parentView: self,
|
||||
environment: preloadEnvironment,
|
||||
contentViewProvider: pageContentViewForPreload(pageNum:reusableView:)
|
||||
)
|
||||
}
|
||||
|
||||
private func pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? {
|
||||
contentViewForPage(pageNum, reusableView: reusableView)
|
||||
}
|
||||
|
||||
public override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
|
||||
if superview != nil {
|
||||
makeUI()
|
||||
}
|
||||
}
|
||||
|
||||
private func attachPageViewControllerIfNeeded() {
|
||||
guard let parentViewController = self.ss_superViewController else { return }
|
||||
|
||||
var didAddToParent = false
|
||||
if pageViewController.parent !== parentViewController {
|
||||
if pageViewController.parent != nil {
|
||||
pageViewController.willMove(toParent: nil)
|
||||
pageViewController.view.removeFromSuperview()
|
||||
pageViewController.removeFromParent()
|
||||
}
|
||||
parentViewController.addChild(pageViewController)
|
||||
didAddToParent = true
|
||||
}
|
||||
|
||||
pageViewController.view.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
|
||||
pageViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
if pageViewController.view.superview !== self {
|
||||
insertSubview(pageViewController.view, at: 0)
|
||||
}
|
||||
|
||||
if didAddToParent {
|
||||
pageViewController.didMove(toParent: parentViewController)
|
||||
}
|
||||
|
||||
if currentDisplayType == .pageCurl, !isTransitioning, pendingTransitionRequest != nil {
|
||||
finishPageCurlTransition(repairFaultIfNeeded: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func detachPageViewControllerIfNeeded() {
|
||||
if pageViewController.parent != nil {
|
||||
pageViewController.willMove(toParent: nil)
|
||||
}
|
||||
pageViewController.view.removeFromSuperview()
|
||||
if pageViewController.parent != nil {
|
||||
pageViewController.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeUI() {
|
||||
guard !didBuildUI else {
|
||||
if currentDisplayType == .pageCurl {
|
||||
attachPageViewControllerIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
didBuildUI = true
|
||||
|
||||
collectionView.dataSource = self
|
||||
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: NSStringFromClass(UICollectionViewCell.self))
|
||||
collectionView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
|
||||
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
if currentDisplayType == .pageCurl {
|
||||
attachPageViewControllerIfNeeded()
|
||||
}
|
||||
preloadController.ensureHostView(in: self)
|
||||
preloadController.initializeSignature(preloadEnvironment)
|
||||
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
|
||||
tapGestureRecognizer.cancelsTouchesInView = false
|
||||
|
||||
}
|
||||
|
||||
@objc private func tapAction(tap: UITapGestureRecognizer) {
|
||||
let point = tap.location(in: tap.view)
|
||||
let hitView = hitTest(point, with: nil)
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.tapAction",
|
||||
"received point=\(RDEpubReaderTapDebug.describe(point)) hitView=\(RDEpubReaderTapDebug.describe(hitView)) display=\(currentDisplayType) currentPage=\(currentPage) toolVisible=\(isShowToolView) transitioning=\(isPageCurlTransitioning)"
|
||||
)
|
||||
if containsTextContentView(in: hitView) {
|
||||
RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because hitView is inside text content view")
|
||||
return
|
||||
}
|
||||
if shouldSuppressChromeToggle(for: hitView, point: point) {
|
||||
RDEpubReaderTapDebug.log("ReaderView.tapAction", "ignored because chrome toggle is suppressed")
|
||||
return
|
||||
}
|
||||
handleResolvedTap(at: point, hitView: hitView, in: tap.view)
|
||||
}
|
||||
|
||||
private func shouldSuppressChromeToggle(for hitView: UIView?, point: CGPoint) -> Bool {
|
||||
if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.suppressChromeToggle",
|
||||
"suppressed by active selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)"
|
||||
)
|
||||
return true
|
||||
}
|
||||
var currentView = hitView
|
||||
while let view = currentView {
|
||||
if let textContentView = view as? RDEPUBTextContentView {
|
||||
let localPoint = convert(point, to: textContentView)
|
||||
let shouldSuppress = textContentView.shouldSuppressReaderTap(at: localPoint)
|
||||
if shouldSuppress {
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.suppressChromeToggle",
|
||||
"suppressed by text content view=\(RDEpubReaderTapDebug.describe(textContentView)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))"
|
||||
)
|
||||
}
|
||||
return shouldSuppress
|
||||
}
|
||||
currentView = view.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func registerSelectionGestureDependenciesIfNeeded(for contentView: UIView) {
|
||||
guard let textContentView = contentView as? RDEPUBTextContentView else { return }
|
||||
let longPressGesture = textContentView.selectionLongPressGestureRecognizer
|
||||
if registeredSelectionLongPressRecognizers.allObjects.contains(where: { $0 === longPressGesture }) {
|
||||
return
|
||||
}
|
||||
tapGestureRecognizer.require(toFail: longPressGesture)
|
||||
registeredSelectionLongPressRecognizers.add(longPressGesture)
|
||||
}
|
||||
|
||||
func updateSelectionTapSuppression(for contentView: UIView, isSuppressed: Bool) {
|
||||
if isSuppressed {
|
||||
if selectionTapSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false {
|
||||
selectionTapSuppressedContentViews.add(contentView)
|
||||
}
|
||||
} else {
|
||||
selectionTapSuppressedContentViews.remove(contentView)
|
||||
}
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.selectionTapSuppression",
|
||||
"contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionTapSuppressedContentViews.allObjects.count)"
|
||||
)
|
||||
}
|
||||
|
||||
func updateSelectionPagingSuppression(for contentView: UIView, isSuppressed: Bool) {
|
||||
if isSuppressed {
|
||||
if selectionPagingSuppressedContentViews.allObjects.contains(where: { $0 === contentView }) == false {
|
||||
selectionPagingSuppressedContentViews.add(contentView)
|
||||
}
|
||||
} else {
|
||||
selectionPagingSuppressedContentViews.remove(contentView)
|
||||
}
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.selectionPagingSuppression",
|
||||
"contentView=\(RDEpubReaderTapDebug.describe(contentView)) suppressed=\(isSuppressed) activeCount=\(selectionPagingSuppressedContentViews.allObjects.count)"
|
||||
)
|
||||
updatePagingInteractionSuppression()
|
||||
}
|
||||
|
||||
func handleContentTap(at point: CGPoint, in sourceView: UIView) {
|
||||
let localPoint = sourceView.convert(point, to: self)
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.handleContentTap",
|
||||
"forwarded from sourceView=\(RDEpubReaderTapDebug.describe(sourceView)) sourcePoint=\(RDEpubReaderTapDebug.describe(point)) localPoint=\(RDEpubReaderTapDebug.describe(localPoint))"
|
||||
)
|
||||
handleResolvedTap(at: localPoint, hitView: nil, in: self)
|
||||
}
|
||||
|
||||
private func handleResolvedTap(at point: CGPoint, hitView: UIView?, in tapView: UIView?) {
|
||||
if isShowToolView {
|
||||
if let top = topToolView, isHitView(hitView, inside: top, point: point) {
|
||||
RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits top tool view")
|
||||
return
|
||||
}
|
||||
if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) {
|
||||
RDEpubReaderTapDebug.log("ReaderView.handleResolvedTap", "ignored because point hits bottom tool view")
|
||||
return
|
||||
}
|
||||
}
|
||||
guard let viewFrame = tapView?.frame else { return }
|
||||
tapEvent = tapRegionHandler.resolveTapEvent(
|
||||
point: point,
|
||||
viewFrame: viewFrame,
|
||||
isToolViewVisible: isShowToolView
|
||||
)
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.handleResolvedTap",
|
||||
"resolved tapEvent=\(tapEvent) point=\(RDEpubReaderTapDebug.describe(point)) viewFrame=\(RDEpubReaderTapDebug.describe(viewFrame))"
|
||||
)
|
||||
}
|
||||
|
||||
private func containsTextContentView(in hitView: UIView?) -> Bool {
|
||||
var currentView = hitView
|
||||
while let view = currentView {
|
||||
if view is RDEPUBTextContentView {
|
||||
return true
|
||||
}
|
||||
currentView = view.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func updatePagingInteractionSuppression() {
|
||||
let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false
|
||||
guard shouldSuppress != isPagingInteractionSuppressed else { return }
|
||||
isPagingInteractionSuppressed = shouldSuppress
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.pagingSuppression",
|
||||
"updated shouldSuppress=\(shouldSuppress) activeContentViews=\(activePagingSuppressionContentViews().count)"
|
||||
)
|
||||
setPagingInteractionEnabled(!shouldSuppress)
|
||||
}
|
||||
|
||||
private func activePagingSuppressionContentViews() -> [UIView] {
|
||||
selectionPagingSuppressedContentViews.allObjects.filter { contentView in
|
||||
guard contentView.window != nil,
|
||||
contentView.isDescendant(of: self),
|
||||
contentView.bounds.isEmpty == false else {
|
||||
return false
|
||||
}
|
||||
let frameInReader = contentView.convert(contentView.bounds, to: self)
|
||||
return frameInReader.intersects(bounds)
|
||||
}
|
||||
}
|
||||
|
||||
private func setPagingInteractionEnabled(_ isEnabled: Bool) {
|
||||
collectionView.isScrollEnabled = isEnabled
|
||||
pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled }
|
||||
}
|
||||
|
||||
public func switchReaderDisplayType(_ displayType: RDEpubReaderView.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()
|
||||
|
||||
if pageDirection == .rightToLeft && displayType != .verticalScroll {
|
||||
collectionView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||
} else {
|
||||
collectionView.transform = .identity
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false) {
|
||||
guard let safePageNum = clampedPageNumber(pageNum) else { return }
|
||||
switch currentDisplayType {
|
||||
case .pageCurl:
|
||||
if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) {
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.transitionToPage",
|
||||
"skip same page page=\(safePageNum) animated=\(animated)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
|
||||
if shouldQueuePageTransition(request) {
|
||||
return
|
||||
}
|
||||
|
||||
let isDualPage = landscapeDualPageEnabled && isLandscape
|
||||
|
||||
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 = RDEpubReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
|
||||
isTransitioning = animated
|
||||
if let rightPage = pair.right {
|
||||
let rightContent = pageViewForDisplay(pageNum: rightPage)
|
||||
let rightVC = RDEpubReaderPageChildViewController(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) ? RDEpubReaderView.blankPageNum : RDEpubReaderView.blankEndPageNum
|
||||
let emptyVC = RDEpubReaderPageChildViewController(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 = RDEpubReaderPageChildViewController(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() {
|
||||
invalidatePageCaches()
|
||||
switchReaderDisplayType(currentDisplayType)
|
||||
topToolView = resolvedTopChromeView()
|
||||
bottomToolView = resolvedBottomChromeView()
|
||||
}
|
||||
|
||||
public func reloadPageCountOnly() {
|
||||
if currentDisplayType == .pageCurl {
|
||||
let totalPages = numberOfPages()
|
||||
if !isPageCurlTransitioning, currentPage >= totalPages, totalPages > 0 {
|
||||
transitionToPage(pageNum: totalPages - 1, animated: false)
|
||||
}
|
||||
} else {
|
||||
collectionView.reloadData()
|
||||
}
|
||||
topToolView = resolvedTopChromeView()
|
||||
bottomToolView = resolvedBottomChromeView()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDEpubReaderView: RDEpubReaderPageNavigating {
|
||||
public func reloadPages() {
|
||||
reloadData()
|
||||
}
|
||||
|
||||
public func transition(to page: Int, animated: Bool) {
|
||||
transitionToPage(pageNum: page, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEpubReaderView: UIGestureRecognizerDelegate {
|
||||
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard gestureRecognizer === tapGestureRecognizer else { return true }
|
||||
|
||||
if selectionTapSuppressedContentViews.allObjects.isEmpty == false {
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.gestureShouldReceive",
|
||||
"return false because selectionTapSuppressedContentViews count=\(selectionTapSuppressedContentViews.allObjects.count)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
let point = touch.location(in: self)
|
||||
if containsTextContentView(in: touch.view) {
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.gestureShouldReceive",
|
||||
"return false because touch.view is inside text content view point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))"
|
||||
)
|
||||
return false
|
||||
}
|
||||
if let topToolView, isHitView(touch.view, inside: topToolView, point: point) {
|
||||
RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit topToolView")
|
||||
return false
|
||||
}
|
||||
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
|
||||
RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit bottomToolView")
|
||||
return false
|
||||
}
|
||||
if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
|
||||
RDEpubReaderTapDebug.log("ReaderView.gestureShouldReceive", "return false because touch hit searchBarView")
|
||||
return false
|
||||
}
|
||||
RDEpubReaderTapDebug.log(
|
||||
"ReaderView.gestureShouldReceive",
|
||||
"return true point=\(RDEpubReaderTapDebug.describe(point)) touchView=\(RDEpubReaderTapDebug.describe(touch.view))"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
public func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
gestureRecognizer === tapGestureRecognizer
|
||||
}
|
||||
}
|
||||
|
||||
private var cellViewKey: Int8 = 0
|
||||
|
||||
extension UIView {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
@available(*, deprecated, message: "Use RDEpubReaderPageProvider instead.")
|
||||
@objc public protocol RDEpubReaderDataSource: NSObjectProtocol {
|
||||
|
||||
func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int
|
||||
|
||||
func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView
|
||||
|
||||
func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String?
|
||||
|
||||
@objc optional func topToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
|
||||
@objc optional func bottomToolView(readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
|
||||
@objc public protocol RDEpubReaderPageProvider: NSObjectProtocol {
|
||||
|
||||
func numberOfPages(in readerView: RDEpubReaderView) -> Int
|
||||
|
||||
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView
|
||||
|
||||
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||
|
||||
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
|
||||
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||
}
|
||||
|
||||
@objc public protocol RDEpubReaderDelegate: NSObjectProtocol {
|
||||
|
||||
func pageNum(readerView: RDEpubReaderView, pageNum: Int)
|
||||
|
||||
@objc optional func readerViewOrientationWillChange(readerView: RDEpubReaderView, isLandscape: Bool)
|
||||
}
|
||||
|
||||
public protocol RDEpubReaderPageNavigating: AnyObject {
|
||||
|
||||
var currentPage: Int { get }
|
||||
|
||||
func reloadPages()
|
||||
|
||||
func transition(to page: Int, animated: Bool)
|
||||
}
|
||||
|
||||
extension RDEpubReaderView {
|
||||
|
||||
public enum DisplayType {
|
||||
|
||||
case pageCurl
|
||||
|
||||
case horizontalScroll
|
||||
|
||||
case verticalScroll
|
||||
}
|
||||
|
||||
public enum PageDirection {
|
||||
|
||||
case leftToRight
|
||||
|
||||
case rightToLeft
|
||||
}
|
||||
}
|
||||
|
||||
final class RDEpubReaderLegacyDataSourceAdapter: NSObject, RDEpubReaderPageProvider {
|
||||
|
||||
weak var dataSource: RDEpubReaderDataSource?
|
||||
|
||||
func numberOfPages(in readerView: RDEpubReaderView) -> Int {
|
||||
dataSource?.pageCountOfReaderView(readerView: readerView) ?? 0
|
||||
}
|
||||
|
||||
func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
|
||||
dataSource?.pageContentView(readerView: readerView, pageNum: index, containerView: reusableView) ?? UIView()
|
||||
}
|
||||
|
||||
func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? {
|
||||
dataSource?.pageIdentifier(readerView: readerView, pageNum: index)
|
||||
}
|
||||
|
||||
func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? {
|
||||
dataSource?.topToolView?(readerView: readerView)
|
||||
}
|
||||
|
||||
func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? {
|
||||
dataSource?.bottomToolView?(readerView: readerView)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user