feat: improve epub reader controls and annotations

This commit is contained in:
shen
2026-07-12 12:23:10 +08:00
parent 063a493b18
commit 8ccb7157b2
36 changed files with 2959 additions and 2160 deletions
@@ -127,14 +127,19 @@ final class RDEpubReaderPreloadController {
return preloaded
}
func updatePageVisibility(visiblePageNumbers: Set<Int>) {
var notifiedViews = Set<ObjectIdentifier>()
func updatePageVisibility(visibleViewIdentifiers: Set<ObjectIdentifier>) {
let cachedViews = pageCurlCachedViews.merging(preloadedPageViews) { current, _ in current }
for (pageNumber, view) in cachedViews {
var notifiedViews = Set<ObjectIdentifier>()
// Cache entries are never the authority for activation: UIPageViewController
// can create two views for the same page number. Keep every non-winning
// instance suspended; the reader activates only its actually attached view.
for view in cachedViews.values {
let identifier = ObjectIdentifier(view)
guard notifiedViews.insert(identifier).inserted else { continue }
guard !visibleViewIdentifiers.contains(identifier),
notifiedViews.insert(identifier).inserted else { continue }
(view as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageNumber))
.readerPageVisibilityDidChange(isVisible: false)
}
}
@@ -147,7 +152,7 @@ final class RDEpubReaderPreloadController {
) {
refreshCacheSignatureIfNeeded(environment)
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment))
let keepSet = Set(targets).union(spreadPageNumbers(startingAt: pageNum, environment: environment))
trimCachedPageViews(keeping: keepSet)
guard !targets.isEmpty else { return }
@@ -194,6 +199,8 @@ final class RDEpubReaderPreloadController {
"cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
)
}
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
if shouldCacheContentView {
preloadedPageViews[targetPage] = contentView
@@ -309,10 +316,6 @@ final class RDEpubReaderPreloadController {
).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()
@@ -351,6 +354,8 @@ final class RDEpubReaderPreloadController {
//
// deinit
let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView
(view as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
view.removeFromSuperview()
if !isHeldOutsidePreloadHost {
(view as? RDEpubReaderPageResourceReleasing)?.releaseResources()
@@ -11,8 +11,11 @@ extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDe
let reusableView = cell.containerView ?? preloadedView
let contentView = contentViewForPage(indexPath.row, reusableView: reusableView)
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(indexPath.row))
.readerPageVisibilityDidChange(isVisible: false)
cell.containerView = contentView
DispatchQueue.main.async { [weak self] in
self?.updatePageContentVisibility()
}
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
@@ -96,7 +96,28 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
let resolvedPage = (pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.pageNum ?? -1
let visibleControllers = pageViewController.viewControllers ?? []
let visibleControllerIdentifiers = Set(visibleControllers.map(ObjectIdentifier.init))
let transitionControllers = previousViewControllers + [
willPreviousTransitionToViewController,
willNextTransitionToViewController,
willTransitionToViewController
].compactMap { $0 }
var hiddenContentViews = Set<ObjectIdentifier>()
// UIPageViewController can ask its data source for a second instance of
// the same page and leave the first one outside our cache dictionaries.
// Explicitly stop every controller that did not win the transition so
// an orphaned Hype WebView cannot keep narrating in the background.
for controller in transitionControllers where !visibleControllerIdentifiers.contains(ObjectIdentifier(controller)) {
guard let childController = controller as? RDEpubReaderPageChildViewController,
let contentView = childController.contentView else { continue }
let identifier = ObjectIdentifier(contentView)
guard hiddenContentViews.insert(identifier).inserted else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
}
if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController {
let pn = firstVC.pageNum
if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum {
@@ -105,6 +126,14 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
}
}
// On cancellation currentPage does not change, so its didSet cannot
// restore visibility. Reassert the actual controllers in both paths.
for case let childController as RDEpubReaderPageChildViewController in visibleControllers {
guard let contentView = childController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(childController.pageNum))
}
predictedPageDirection = nil
finishPageCurlTransition()
}
@@ -527,10 +527,13 @@ public class RDEpubReaderView: UIView {
let pageView = preloadController.pageViewForDisplay(
pageNum: pageNum,
environment: preloadEnvironment,
contentViewProvider: pageContentViewForDisplay(pageNum:reusableView:)
contentViewProvider: contentViewForPage(_:reusableView:)
)
// A data-source request is only a candidate. UIPageViewController may
// ask for duplicate instances of the current page; activation happens
// after the winning controller is actually installed.
(pageView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(pageNum))
.readerPageVisibilityDidChange(isVisible: false)
return pageView
}
@@ -551,10 +554,6 @@ public class RDEpubReaderView: UIView {
return contentViewForPage(pageNum, reusableView: reusableView)
}
private func pageContentViewForDisplay(pageNum: Int, reusableView: UIView?) -> UIView? {
return contentViewForPage(pageNum, reusableView: reusableView)
}
func isReaderPageVisible(_ pageNum: Int) -> Bool {
guard currentPage >= 0 else { return pageNum == 0 }
if landscapeDualPageEnabled && isLandscape {
@@ -564,7 +563,7 @@ public class RDEpubReaderView: UIView {
return pageNum == currentPage
}
private func updatePageContentVisibility() {
func updatePageContentVisibility() {
var visiblePageNumbers = Set<Int>()
if currentPage >= 0 {
visiblePageNumbers.insert(currentPage)
@@ -574,20 +573,57 @@ public class RDEpubReaderView: UIView {
}
}
preloadController.updatePageVisibility(visiblePageNumbers: visiblePageNumbers)
for viewController in pageViewController.viewControllers ?? [] {
guard let pageController = viewController as? RDEpubReaderPageChildViewController,
let contentView = pageController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageController.pageNum))
let pageControllers: [RDEpubReaderPageChildViewController]
let visibleCellContents: [(pageNumber: Int, contentView: UIView)]
if currentDisplayType == .pageCurl {
pageControllers = (pageViewController.viewControllers ?? []).compactMap {
$0 as? RDEpubReaderPageChildViewController
}
visibleCellContents = []
} else {
pageControllers = []
visibleCellContents = collectionView.indexPathsForVisibleItems.compactMap { indexPath in
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell,
let contentView = cell.containerView else { return nil }
return (indexPath.item, contentView)
}
}
for indexPath in collectionView.indexPathsForVisibleItems {
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell,
let contentView = cell.containerView else { continue }
for pageController in pageControllers where !visiblePageNumbers.contains(pageController.pageNum) {
guard let contentView = pageController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(indexPath.item))
.readerPageVisibilityDidChange(isVisible: false)
}
for entry in visibleCellContents where !visiblePageNumbers.contains(entry.pageNumber) {
(entry.contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
}
// Suspend every cached non-winner before the attached winner below is
// activated. Object identity matters because a page number can have
// multiple UIPageViewController candidates.
let visibleViewIdentifiers = Set(
pageControllers.compactMap { pageController -> ObjectIdentifier? in
guard visiblePageNumbers.contains(pageController.pageNum),
let contentView = pageController.contentView else { return nil }
return ObjectIdentifier(contentView)
} + visibleCellContents.compactMap { entry -> ObjectIdentifier? in
guard visiblePageNumbers.contains(entry.pageNumber) else { return nil }
return ObjectIdentifier(entry.contentView)
}
)
preloadController.updatePageVisibility(visibleViewIdentifiers: visibleViewIdentifiers)
for pageController in pageControllers where visiblePageNumbers.contains(pageController.pageNum) {
guard let contentView = pageController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: true)
}
for entry in visibleCellContents where visiblePageNumbers.contains(entry.pageNumber) {
(entry.contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: true)
}
}
@@ -629,6 +665,11 @@ public class RDEpubReaderView: UIView {
}
private func detachPageViewControllerIfNeeded() {
for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] {
guard let contentView = childController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
}
if pageViewController.parent != nil {
pageViewController.willMove(toParent: nil)
}
@@ -882,6 +923,17 @@ public class RDEpubReaderView: UIView {
}
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
attachPageViewControllerIfNeeded()
// Programmatic setViewControllers transitions do not call the
// UIPageViewControllerDelegate didFinishAnimating callback. Stop
// the outgoing controllers explicitly before replacing them, even
// when a duplicate page instance has fallen out of our cache map.
for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] {
guard let contentView = childController.contentView else { continue }
(contentView as? RDEpubReaderPageVisibilityHandling)?
.readerPageVisibilityDidChange(isVisible: false)
}
if isDualPage {
let pair = dualPagePair(for: safePageNum)
let leftContent = pageViewForDisplay(pageNum: pair.left)
@@ -903,7 +955,11 @@ public class RDEpubReaderView: UIView {
self.finishPageCurlTransition()
}
}
let pageChanged = currentPage != pair.left
currentPage = pair.left
if !pageChanged {
updatePageContentVisibility()
}
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
} else {
let contentView = pageViewForDisplay(pageNum: safePageNum)
@@ -913,7 +969,11 @@ public class RDEpubReaderView: UIView {
guard let self else { return }
self.finishPageCurlTransition()
}
let pageChanged = currentPage != safePageNum
currentPage = safePageNum
if !pageChanged {
updatePageContentVisibility()
}
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
}
default:
@@ -921,7 +981,11 @@ public class RDEpubReaderView: UIView {
collectionView.layoutIfNeeded()
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
let pageChanged = currentPage != safePageNum
currentPage = safePageNum
if !pageChanged {
updatePageContentVisibility()
}
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
}
}