import UIKit protocol RDReaderCachePolicyProviding { var shouldAvoidReaderPageCaching: Bool { get } } final class RDReaderPreloadController { 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: RDReaderView.DisplayType let isLandscape: Bool let pagesPerScreen: Int let boundsSize: CGSize let landscapeDualPageEnabled: Bool let coverPageIndex: Int? let totalPages: Int let spreadResolver: RDReaderSpreadResolver } private struct CacheSignature: Equatable { let displayType: RDReaderView.DisplayType let isLandscape: Bool let pagesPerScreen: Int let boundsSize: CGSize } func setHostFrame(_ frame: CGRect) { preloadHostView.frame = frame } func ensureHostView(in parentView: UIView) { guard preloadHostView.superview == nil else { return } preloadHostView.isHidden = true preloadHostView.isUserInteractionEnabled = false preloadHostView.clipsToBounds = true preloadHostView.frame = parentView.bounds preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight] parentView.insertSubview(preloadHostView, at: 0) } func initializeSignature(_ environment: Environment) { cacheSignature = currentCacheSignature(environment) } func invalidate(environment: Environment) { pageCurlCachedViews.values.forEach { $0.removeFromSuperview() } preloadedPageViews.values.forEach { $0.removeFromSuperview() } pageCurlCachedViews.removeAll() preloadedPageViews.removeAll() cacheSignature = currentCacheSignature(environment) } func pageViewForDisplay( pageNum: Int, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView? ) -> UIView { let view: UIView if let reusableView = detachedReusablePageView(for: pageNum) { view = reusableView } else { view = contentViewProvider(pageNum, nil) ?? UIView() } 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() return preloaded } func prime( around pageNum: Int, preferredForward: Bool? = nil, parentView: UIView, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView? ) { refreshCacheSignatureIfNeeded(environment) let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment) let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment)) trimCachedPageViews(keeping: keepSet) guard !targets.isEmpty else { return } ensureHostView(in: parentView) preloadHostView.frame = parentView.bounds for targetPage in targets { let contentView: UIView if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView { contentView = existing } else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil { contentView = cached } else { contentView = contentViewProvider(targetPage, nil) ?? UIView() } 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 != RDReaderView.blankPageNum && pageNum != RDReaderView.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? RDReaderCachePolicyProviding { 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 { 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 = [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 { 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) { pageCurlCachedViews = pageCurlCachedViews.filter { key, value in let keep = pageNumbers.contains(key) if !keep { value.removeFromSuperview() } return keep } preloadedPageViews = preloadedPageViews.filter { key, value in let keep = pageNumbers.contains(key) if !keep { value.removeFromSuperview() } return keep } } private func forecastTargets(around pageNum: Int, preferredForward: Bool?, environment: Environment) -> [Int] { guard environment.totalPages > 0, shouldCachePage(pageNum, environment: environment) else { return [] } var targets = Set() var previousAnchor = pageNum for _ in 0..