ReadViewSDK/Sources/RDReaderView/ReaderView/RDReaderView.swift
shenlei 0e7c0577e3 feat: add in-reader search and restructure documentation
- Add RDEPUBReaderSearchBarView with animated show/hide, keyword
  navigation, and match counting integrated into the reader controller
- Restructure docs: replace scattered design docs with consolidated
  BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md
- Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests
- Add scripts/run_ui_regression.sh and summarize_ui_results.py for
  automated UI test execution and reporting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 17:34:50 +08:00

819 lines
34 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// RDReaderView.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/6.
//
// RDReaderView
// UIView RDReaderView
// 1. 仿
// 2. 1/31/31/3
// 3.
// 4. (LTR)(RTL)
// 5. DataSource/Delegate
// 6.
//
// RDReaderView
// EPUBCore EPUBTextRendering RDReaderView EPUBUI UI
//
import UIKit
///
/// RDReaderView
///
///
/// 使
/// 1. ``dataSource``
/// 2. ``delegate``
/// 3. ``switchReaderDisplayType(_:)``
/// 4. ``reloadData()``
///
///
/// - pageCurl / horizontalScroll / verticalScroll
/// -
/// - RTL
/// - //
/// -
public class RDReaderView: UIView {
///
///
enum TapEvent {
///
case none
/// 1/3RTL
case left
/// 1/3/
case center
/// 1/3RTL
case right
}
/// 仿使 UIPageViewController
/// pageCurl 仿
lazy var pageViewController: UIPageViewController = {
let pageVC = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: nil)
pageVC.delegate = self
pageVC.dataSource = self
return pageVC
}()
///
lazy var layout: RDReaderFlowLayout = {
let layout = RDReaderFlowLayout(displayType: .horizontalScroll)
layout.dataSource = self
layout.delegate = self
return layout
}()
/// 使 UICollectionView
/// horizontalScroll verticalScroll
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 = RDReaderSpreadResolver()
private let tapRegionHandler = RDReaderTapRegionHandler()
let preloadController = RDReaderPreloadController()
var pagingController = RDReaderPagingController()
///
/// delegate
/// -1
public var currentPage: Int = -1 {
didSet {
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDReaderDelegate.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
}
}
}
///
/// 使 adjacentDualPage +1
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)
}
}
///
/// 使 adjacentDualPage -1
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 = RDReaderLegacyDataSourceAdapter()
///
public weak var dataSource: RDReaderDataSource? {
didSet {
legacyDataSourceAdapter.dataSource = dataSource
}
}
/// ``dataSource``
public weak var pageProvider: RDReaderPageProvider?
///
public weak var delegate: RDReaderDelegate? = nil
/// 仿
public var currentDisplayType: RDReaderView.DisplayType = .pageCurl
/// / 0.3
public var toolViewAnimationDuration: TimeInterval = 0.3
///
var onToolViewVisibilityChanged: ((Bool) -> Void)?
/// tapCenter
var searchBarView: UIView?
///
public var landscapeDualPageEnabled: Bool = false
/// /
public var pageDirection: RDReaderView.PageDirection = .leftToRight
///
/// nil 0+1, 2+3, 4+5...
public var coverPageIndex: Int? = nil
private var resolvedPageProvider: RDReaderPageProvider? {
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
}
///
/// 12
public var pagesPerScreen: Int {
if !landscapeDualPageEnabled { return 1 }
if currentDisplayType == .verticalScroll { return 1 }
return isLandscape ? 2 : 1
}
///
/// - Parameter pageNum:
/// - Returns:
public func isFullScreenPage(_ pageNum: Int) -> Bool {
spreadResolver.isFullScreenPage(
pageNum,
landscapeDualPageEnabled: landscapeDualPageEnabled,
isLandscape: isLandscape,
coverPageIndex: coverPageIndex
)
}
///
///
/// - Parameter pageNum:
/// - Returns: (, (nil))
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
let totalPages = numberOfPages()
return spreadResolver.dualPagePair(
for: pageNum,
totalPages: totalPages,
coverPageIndex: coverPageIndex
)
}
/// /
/// - Parameters:
/// - pageNum:
/// - forward: true=false=
/// - Returns: nil
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)
}
/// layoutSubviews
private var previousIsLandscape: Bool?
/// key
var contentViews = [String : UIView.Type]()
/// pageCurl
var willPreviousTransitionToViewController: UIViewController? = nil
/// pageCurl
var willNextTransitionToViewController: UIViewController? = nil
/// pageCurl
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 }
}
/// UI
private var didBuildUI: Bool {
get { pagingController.didBuildUI }
set { pagingController.didBuildUI = newValue }
}
/// true=false=
var predictedPageDirection: Bool?
/// 1
public var preloadRadius: Int {
get { preloadController.radius }
set { preloadController.radius = newValue }
}
/// pageCurl
static let blankPageNum = Int.max
/// pageCurl
static let blankEndPageNum = Int.max - 1
/// pageCurl
private typealias PageTransitionRequest = RDReaderPagingController.PageTransitionRequest
private var pendingTransitionRequest: PageTransitionRequest? {
get { pagingController.pendingTransitionRequest }
set { pagingController.pendingTransitionRequest = newValue }
}
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
// RunLoop layoutSubviews reloadData/layoutIfNeeded
// collectionView
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.orientationChanged(isNowLandscape: nowLandscape)
}
} else if previousIsLandscape == nil {
previousIsLandscape = nowLandscape
layout.isLandscapeDualPage = landscapeDualPageEnabled && nowLandscape
}
}
///
///
/// - pageCurl UIPageViewController spineLocation
/// -
/// - Parameter isNowLandscape:
private func orientationChanged(isNowLandscape: Bool) {
let savedPage = max(0, currentPage)
// 1.
delegate?.readerViewOrientationWillChange?(readerView: self, isLandscape: isNowLandscape)
// 2.
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
layout.coverPageIndex = coverPageIndex
switch currentDisplayType {
case .pageCurl:
// 仿 spineLocation PageViewController
rebuildPageViewController()
transitionToPage(pageNum: savedPage)
primePageCache(around: savedPage, preferredForward: predictedPageDirection)
default:
//
UIView.performWithoutAnimation {
// 使
collectionView.collectionViewLayout.invalidateLayout()
collectionView.reloadData()
collectionView.layoutIfNeeded()
// 3.
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)
}
}
}
/// spine UIPageViewController
/// - Parameter isDualPage: .mid
/// - Returns: UIPageViewController
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
}
/// UIPageViewController
/// spineLocation
/// PageViewController
private func rebuildPageViewController() {
detachPageViewControllerIfNeeded()
let isDualPage = landscapeDualPageEnabled && isLandscape
let pageVC = createPageViewController(isDualPage: isDualPage)
pageViewController = pageVC
attachPageViewControllerIfNeeded()
}
// MARK: - UIPageViewController Fault Detection
/// UIPageViewController
///
/// - Parameter pageVC: UIPageViewController
/// - Returns: true
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? RDReaderPageChildViewController }
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) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum)
return childViewControllers[0].pageNum != expectedPair.left
|| childViewControllers[1].pageNum != expectedRightPage
}
/// UIPageViewController
/// PageViewController
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)
}
}
/// pageCurl
private func shouldQueuePageTransition(_ request: PageTransitionRequest) -> Bool {
pagingController.shouldQueuePageTransition(request, currentDisplayType: currentDisplayType)
}
/// pageCurl
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)
}
}
// MARK: - Preload / Forecast
private var preloadEnvironment: RDReaderPreloadController.Environment {
RDReaderPreloadController.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)
}
/// UI
public override func didMoveToSuperview() {
super.didMoveToSuperview()
if superview != nil {
makeUI()
}
}
/// pageViewController
///
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)
}
}
/// pageViewController
/// willMove/didMove
private func detachPageViewControllerIfNeeded() {
if pageViewController.parent != nil {
pageViewController.willMove(toParent: nil)
}
pageViewController.view.removeFromSuperview()
if pageViewController.parent != nil {
pageViewController.removeFromParent()
}
}
/// UI
/// cell
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)
// touchUpInside
tapGestureRecognizer.cancelsTouchesInView = false
}
///
/// tapEvent
@objc private func tapAction(tap: UITapGestureRecognizer) {
let point = tap.location(in: tap.view)
if isShowToolView {
let hitView = hitTest(point, with: nil)
if let top = topToolView, isHitView(hitView, inside: top, point: point) { return }
if let bottom = bottomToolView, isHitView(hitView, inside: bottom, point: point) { return }
}
guard let viewFrame = tap.view?.frame else { return }
tapEvent = tapRegionHandler.resolveTapEvent(
point: point,
viewFrame: viewFrame,
isToolViewVisible: isShowToolView
)
}
/// 仿//
/// PageViewController CollectionView
/// - Parameter displayType:
public func switchReaderDisplayType(_ displayType: RDReaderView.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()
// RTL collectionView
if pageDirection == .rightToLeft && displayType != .verticalScroll {
collectionView.transform = CGAffineTransform(scaleX: -1, y: 1)
} else {
collectionView.transform = .identity
}
// collectionView frame
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)
}
}
///
///
/// - pageCurl UIPageViewController.setViewControllers
/// - setContentOffset
/// - Parameters:
/// - pageNum: item
/// - animated:
public func transitionToPage(pageNum: Int, animated: Bool = false) {
guard let safePageNum = clampedPageNumber(pageNum) else { return }
switch currentDisplayType {
case .pageCurl:
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
if shouldQueuePageTransition(request) {
return
}
let isDualPage = landscapeDualPageEnabled && isLandscape
// RTL
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 = RDReaderPageChildViewController(contentView: leftContent, pageNum: pair.left)
isTransitioning = animated
if let rightPage = pair.right {
let rightContent = pageViewForDisplay(pageNum: rightPage)
let rightVC = RDReaderPageChildViewController(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) ? RDReaderView.blankPageNum : RDReaderView.blankEndPageNum
let emptyVC = RDReaderPageChildViewController(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 = RDReaderPageChildViewController(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() {
switchReaderDisplayType(currentDisplayType)
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}
///
public func reloadPageCountOnly() {
collectionView.reloadData()
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension RDReaderView: RDReaderPageNavigating {
public func reloadPages() {
reloadData()
}
public func transition(to page: Int, animated: Bool) {
transitionToPage(pageNum: page, animated: animated)
}
}
extension RDReaderView: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard gestureRecognizer === tapGestureRecognizer else { return true }
let point = touch.location(in: self)
if let topToolView, isHitView(touch.view, inside: topToolView, point: point) {
return false
}
if let bottomToolView, isHitView(touch.view, inside: bottomToolView, point: point) {
return false
}
if let searchBarView, isHitView(touch.view, inside: searchBarView, point: point) {
return false
}
return true
}
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === tapGestureRecognizer
}
}
private var cellViewKey: Int8 = 0
/// UIView UIViewController
extension UIView {
/// 沿 UIViewController
///
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
}
}