Files
ReadViewSDK/Sources/RDPDFReaderView/Sources/RDPDFReaderContainerView.swift
T
shenlei 95cead5863 修复 PDF 阅读交互稳定性并统一项目技能管理
PDF 阅读器在慢速双指缩放、竖向滚动页高异步更新、页面请求超时和绘画橡皮擦等场景中,存在视口跳动、迟到结果被错误丢弃、缓存边界不明确及笔迹显示与持久化不同步的问题;同一本书的标注读取和并发写入也会产生不必要的磁盘访问或覆盖风险。\n\n调整缩放 inset 计算和竖向阅读锚点恢复,避免缩放变换被重复计入、真实页高到达时改变当前阅读位置。页面请求改为带 token 的超时重试机制,可在缓存窗口内接收有效迟到结果并提供失败重试入口;页面大图、OCR、笔迹和描述缓存统一按当前页前后两页收敛,PDF 标识改为完整内容 SHA-256,避免同名或相近文件复用错误阅读状态。\n\n绘画会话内按路径实时重绘,退出会话后使用按图层派生的位图缓存,确保橡皮擦即时作用于已提交笔迹;同时为笔迹持久化增加版本控制、为标注读写增加同步保护和内存快照,降低复用与并发场景下的状态错乱。\n\n将项目技能统一迁入 .agents/skills,并以 .claude/skills 相对软链接供 Claude Code 读取;新增详细 Git 提交技能,自动审查改动、生成中文提交说明并约束安全推送。

验证:所有项目技能通过 quick_validate;执行 xcodebuild -workspace ReadViewDemo/ReadViewDemo.xcworkspace -scheme ReadViewDemo -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build,构建成功。
2026-07-24 14:40:02 +09:00

863 lines
42 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.
import UIKit
import SnapKit
@objc public protocol RDPDFReaderDataSource: NSObjectProtocol {
func pageCountOfReaderView(readerView: RDPDFReaderView) -> Int
func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView
@objc optional func topToolView(readerView: RDPDFReaderView) -> UIView?
@objc optional func bottomToolView(readerView: RDPDFReaderView) -> UIView?
}
@objc public protocol RDPDFReaderDelegate: NSObjectProtocol {
func pageNum(readerView: RDPDFReaderView, pageNum: Int)
@objc optional func toolViewVisibilityChanged(readerView: RDPDFReaderView, isVisible: Bool)
}
/// 让宿主页面向 SDK 报告点击和缩放状态,SDK 不依赖具体 PDF 页面类型。
public protocol RDPDFReaderPageInteractable: AnyObject {
var readerContentTapHandler: ((CGPoint) -> Void)? { get set }
var readerZoomStateChangedHandler: ((Bool) -> Void)? { get set }
/// 文本/区域选区激活时,阅读器暂停翻页和内容点击,避免长按拖选被识别为翻页。
var readerSelectionStateChangedHandler: ((Bool) -> Void)? { get set }
var readerIsZoomed: Bool { get }
var readerHasActiveTextSelection: Bool { get }
func readerSetInternalGesturesEnabled(_ enabled: Bool)
func readerClearTextSelection()
/// 显式跳页前提交正在进行的笔迹,避免页面视图被替换时丢失最后一笔。
func readerFinishActiveDrawingStroke()
func readerBeginExternalPinch(at point: CGPoint)
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint)
func readerEndExternalPinch()
func readerBeginExternalPan()
func readerUpdateExternalPan(translation: CGPoint)
func readerEndExternalPan()
}
public extension RDPDFReaderPageInteractable {
var readerSelectionStateChangedHandler: ((Bool) -> Void)? {
get { nil }
set {}
}
var readerHasActiveTextSelection: Bool { false }
func readerSetInternalGesturesEnabled(_ enabled: Bool) {}
func readerClearTextSelection() {}
func readerFinishActiveDrawingStroke() {}
func readerBeginExternalPinch(at point: CGPoint) {}
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {}
func readerEndExternalPinch() {}
func readerBeginExternalPan() {}
func readerUpdateExternalPan(translation: CGPoint) {}
func readerEndExternalPan() {}
}
public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
public enum DisplayType { case pageCurl, horizontalScroll, verticalScroll, horizontalCoverScroll }
enum CurlPanInteraction {
case idle
case pageTurn(startPage: Int)
case contentPan
case pinching
}
public weak var dataSource: RDPDFReaderDataSource?
public weak var delegate: RDPDFReaderDelegate?
public var currentDisplayType: DisplayType = .pageCurl
/// 与 EPUB 阅读器一致:是否允许横屏在非竖滑模式下显示双页。
public var landscapeDualPageEnabled: Bool = false {
didSet {
guard oldValue != landscapeDualPageEnabled else { return }
setNeedsLayout()
}
}
/// 手机横屏竖滑:每页 cell 高度是宽度适配后的纸张高度,纵向超出一屏的内容
/// 由外层 collectionView 连续滚动承接,页面内部不再嵌套竖向滚动。
/// 参数为页码与 cell 宽度;返回 nil(页面尚未加载)时回退为一屏高。
public var verticalScrollWidthFitPageHeightProvider: ((Int, CGFloat) -> CGFloat?)?
/// 由宿主在方向变化时设置。同为竖滑的横竖屏切换不会触发 displayType 变更,
/// 因此这里主动重建 cell,让新的适配模式和 cell 高度立即生效。
public var verticalScrollWidthFitEnabled = false {
didSet {
guard oldValue != verticalScrollWidthFitEnabled,
currentDisplayType == .verticalScroll else { return }
collectionView.collectionViewLayout.invalidateLayout()
collectionView.reloadData()
if currentPage >= 0 { transitionToPage(pageNum: currentPage, animated: false) }
}
}
public internal(set) var currentPage = -1 { didSet { if oldValue != currentPage { synchronizePageState(); delegate?.pageNum(readerView: self, pageNum: currentPage) } } }
public internal(set) var isToolViewVisible = false
private let layout = UICollectionViewFlowLayout()
let spreadResolver = RDPDFReaderSpreadResolver()
let pagingController = RDPDFReaderPagingController()
lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
let curlHostView = UIView()
/// 放大态不复用翻页中的页面视图:以当前 spread 重建一个整体画布,
/// 从根源上隔离 UIPageViewController/UICollectionView 的翻页手势。
let zoomOverlayView = RDPDFReaderZoomOverlayView()
lazy var readerPinchGestureRecognizer: UIPinchGestureRecognizer = {
let gesture = UIPinchGestureRecognizer(target: self, action: #selector(handleReaderPinch(_:)))
gesture.delegate = self
return gesture
}()
lazy var readerDoubleTapGestureRecognizer: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleReaderDoubleTap(_:)))
gesture.numberOfTapsRequired = 2
gesture.delegate = self
return gesture
}()
var nativeCurlPageController: UIPageViewController?
lazy var curlPanGestureRecognizer: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleCurlPan(_:)))
gesture.minimumNumberOfTouches = 1
gesture.maximumNumberOfTouches = 1
gesture.delegate = self
return gesture
}()
lazy var curlPinchGestureRecognizer: UIPinchGestureRecognizer = {
let gesture = UIPinchGestureRecognizer(target: self, action: #selector(handleCurlPinch(_:)))
gesture.cancelsTouchesInView = false
gesture.delegate = self
return gesture
}()
var curlContentView: UIView?
var curlPanInteraction = CurlPanInteraction.idle
var isPagingEnabled = true
var isCurrentPageZoomed = false
var isCurrentPageTextSelectionActive = false
var topToolView: UIView?
var bottomToolView: UIView?
var topToolViewHeightConstraint: Constraint?
var bottomToolViewHeightConstraint: Constraint?
var isTransitioning: Bool {
get { pagingController.isTransitioning }
set { pagingController.isTransitioning = newValue }
}
var pendingPage: (Int, Bool)? {
get { pagingController.pendingTransition.map { ($0.page, $0.animated) } }
set { pagingController.pendingTransition = newValue.map { (page: $0.0, animated: $0.1) } }
}
private var appliedLandscapeSpread = false
/// UICollectionViewFlowLayout 不会因为仅修改 itemSize 就立即丢弃旋转前的布局缓存。
/// 记录上一次实际应用的尺寸,横竖屏变化时主动失效,避免横屏首帧仍排入两个竖屏 cell。
private var appliedCollectionItemSize = CGSize.zero
/// 手机横屏宽度适配下,远跳的目标页可能先按占位高度布局,待真实页面数据
/// 到达后需要重新将目标页锚定到顶部。
var pendingWidthFitJumpPage: Int?
/// 宽度适配页的真实高度异步到达时,用页内相对位置恢复视口,避免前序 cell
/// 从占位高度扩展后把正在阅读的内容顶走。
private struct VerticalReadingAnchor {
let page: Int
let offsetFromPageTop: CGFloat
}
private var reusableTypes: [String: UIView.Type] = [:]
public override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setup() {
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
collectionView.backgroundColor = .clear
collectionView.accessibilityIdentifier = "epub.reader.paging"
collectionView.dataSource = self
collectionView.delegate = self
collectionView.register(RDPDFReaderContentCell.self, forCellWithReuseIdentifier: "fallback")
collectionView.frame = bounds
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(collectionView)
// 双页原生卷页控制器在中轴会露出宿主底色;保持透明,避免出现人为灰色中缝。
curlHostView.backgroundColor = .clear
curlHostView.clipsToBounds = true
curlHostView.accessibilityIdentifier = "pdf.reader.pageCurlHost"
curlHostView.frame = bounds
curlHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
curlHostView.addGestureRecognizer(curlPanGestureRecognizer)
curlHostView.addGestureRecognizer(curlPinchGestureRecognizer)
addSubview(curlHostView)
zoomOverlayView.frame = bounds
zoomOverlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
zoomOverlayView.isHidden = true
zoomOverlayView.onZoomStateChanged = { [weak self] zoomed in
guard let self else { return }
self.isCurrentPageZoomed = zoomed
self.updatePagingState()
if !zoomed { self.dismissZoomOverlay() }
}
addSubview(zoomOverlayView)
addGestureRecognizer(readerPinchGestureRecognizer)
addGestureRecognizer(readerDoubleTapGestureRecognizer)
}
public override func layoutSubviews() {
super.layoutSubviews()
let spread = usesLandscapeSpread
let itemSize = spread
? CGSize(width: bounds.width / 2, height: bounds.height)
: bounds.size
let itemSizeChanged = appliedCollectionItemSize != itemSize
if itemSizeChanged {
appliedCollectionItemSize = itemSize
layout.itemSize = itemSize
layout.invalidateLayout()
collectionView.collectionViewLayout.invalidateLayout()
collectionView.layoutIfNeeded()
// invalidateLayout 会保留旧 contentOffset;按新页面尺寸重新锚定当前页,
// 否则旋转后会停在半页或仍看到相邻页。
if currentDisplayType != .pageCurl, currentPage >= 0 {
let displayedPage = usesLandscapeSpread
? spreadResolver.pair(for: currentPage, totalPages: pageCount()).left
: currentPage
let offset = currentDisplayType == .verticalScroll
// 宽度适配竖滑下各 cell 高度不同,不能按等高乘法换算,取真实布局位置。
? CGPoint(x: 0, y: clampedVerticalOffset(anchorY(forItem: displayedPage)))
: CGPoint(x: CGFloat(usesLandscapeSpread ? displayedPage / 2 : displayedPage) * bounds.width, y: 0)
collectionView.setContentOffset(offset, animated: false)
}
}
curlHostView.frame = bounds
curlContentView?.frame = curlHostView.bounds
updateToolViewHeightConstraintsIfNeeded()
guard appliedLandscapeSpread != spread else { return }
// 旋转会使 collection/UIPageViewController 重排其外壳。先归还实际页面,
// 再切换单页/双页结构,避免 Overlay 持有已失效的父视图。
if zoomOverlayView.isPresented { dismissZoomOverlay() }
appliedLandscapeSpread = spread
layout.invalidateLayout()
guard currentPage >= 0 else { return }
// 旋转后必须重新创建当前内容:仿真模式从单页切成 spread,反之亦然。
curlContentView?.removeFromSuperview()
curlContentView = nil
DispatchQueue.main.async { [weak self] in
self?.switchReaderDisplayType(self?.currentDisplayType ?? .pageCurl)
}
}
public func reloadData() {
dismissZoomOverlay()
topToolView = dataSource?.topToolView?(readerView: self)
bottomToolView = dataSource?.bottomToolView?(readerView: self)
curlContentView?.removeFromSuperview()
curlContentView = nil
curlPanInteraction = .idle
currentPage = -1
switchReaderDisplayType(currentDisplayType)
}
public func switchReaderDisplayType(_ type: DisplayType) {
dismissZoomOverlay()
currentDisplayType = type
// pagesPerScreen 同时依赖方向和翻页方式。旋转过程中先按仿真模式完成的
// 横屏布局可能已经把 itemSize 缓存成半屏;切到手机强制竖滑后必须立即
// 重新执行自身布局,不能等待下一次点击或系统布局事件。
setNeedsLayout()
layoutIfNeeded()
curlPanInteraction = .idle
(curlContentView as? RDPDFReaderPageInteractable)?.readerClearTextSelection()
if currentPage < 0 { currentPage = 0 }
let isCurl = type == .pageCurl
collectionView.isHidden = isCurl
curlHostView.isHidden = !isCurl
if isCurl { rebuildNativeCurlController() }
isCurrentPageZoomed = false
isCurrentPageTextSelectionActive = false
updatePagingState()
(curlContentView as? RDPDFReaderPageInteractable)?.readerSetInternalGesturesEnabled(!isCurl)
layout.scrollDirection = type == .verticalScroll ? .vertical : .horizontal
collectionView.isPagingEnabled = type != .verticalScroll
collectionView.reloadData()
transitionToPage(pageNum: currentPage, animated: false)
}
public func transitionToPage(pageNum: Int, animated: Bool = false) {
guard let page = clamped(pageNum) else { return }
// 目录、缩略图、书签和宿主 API 是显式导航:提交笔迹并清除选区后允许跳页。
// `isPagingEnabled` 仅限制用户的翻页手势,不能阻断这些入口。
visiblePageContentViews().compactMap { $0 as? RDPDFReaderPageInteractable }.forEach {
$0.readerFinishActiveDrawingStroke()
$0.readerClearTextSelection()
}
isCurrentPageTextSelectionActive = false
// 目录、缩略图、书签及宿主 API 均通过此入口进行显式跳页。放大层会接管
// 当前页视图,必须先归还该视图,才能让目标页正常显示。
if isCurrentPageZoomed { dismissZoomOverlay() }
let displayedPage = usesLandscapeSpread ? spreadResolver.pair(for: page, totalPages: pageCount()).left : page
if usesWidthFitVerticalScroll { pendingWidthFitJumpPage = displayedPage }
if currentDisplayType == .pageCurl {
transitionCurl(to: displayedPage, animated: animated)
} else {
collectionView.layoutIfNeeded()
let offset = currentDisplayType == .verticalScroll
? CGPoint(x: 0, y: clampedVerticalOffset(anchorY(forItem: displayedPage)))
: CGPoint(x: CGFloat(usesLandscapeSpread ? displayedPage / 2 : displayedPage) * bounds.width, y: 0)
collectionView.setContentOffset(offset, animated: animated)
currentPage = displayedPage
}
}
/// 竖滑模式下页面的实际顶部位置。宽度适配时 cell 高度按页给出,
/// 布局属性是唯一可靠来源;尚未布局时回退为等高换算。
func anchorY(forItem item: Int) -> CGFloat {
collectionView.layoutAttributesForItem(at: IndexPath(item: item, section: 0))?.frame.minY
?? CGFloat(item) * bounds.height
}
func clampedVerticalOffset(_ y: CGFloat) -> CGFloat {
let maxY = max(0, collectionView.contentSize.height - collectionView.bounds.height)
return min(max(0, y), maxY)
}
var usesWidthFitVerticalScroll: Bool {
currentDisplayType == .verticalScroll && verticalScrollWidthFitEnabled
}
/// 宽度适配竖滑下页面异步加载完成后,cell 高度会从"一屏高"变为真实纸张高度,
/// 宿主拿到页面数据时调用此方法刷新布局。
public func invalidateWidthFitLayoutIfNeeded(updatedPage: Int? = nil) {
guard usesWidthFitVerticalScroll else { return }
let readingAnchor = verticalReadingAnchor()
collectionView.collectionViewLayout.invalidateLayout()
DispatchQueue.main.async { [weak self] in
guard let self, self.usesWidthFitVerticalScroll else { return }
// 手指或惯性滚动拥有位置时不回写,避免与用户输入竞争。
guard !self.collectionView.isTracking,
!self.collectionView.isDragging,
!self.collectionView.isDecelerating else {
// 用户已接管显式跳页后的滚动位置,不能把遗留锚点留给后续任意
// 页面加载,否则松手后会被意外拉回目标页顶部。
if updatedPage == self.pendingWidthFitJumpPage {
self.pendingWidthFitJumpPage = nil
}
return
}
self.collectionView.layoutIfNeeded()
if let updatedPage,
updatedPage == self.pendingWidthFitJumpPage,
self.currentPage == updatedPage {
self.collectionView.setContentOffset(
CGPoint(x: 0, y: self.clampedVerticalOffset(self.anchorY(forItem: updatedPage))),
animated: false
)
self.pendingWidthFitJumpPage = nil
} else if let readingAnchor {
let targetY = self.anchorY(forItem: readingAnchor.page) + readingAnchor.offsetFromPageTop
self.collectionView.setContentOffset(
CGPoint(x: 0, y: self.clampedVerticalOffset(targetY)),
animated: false
)
}
}
}
private func verticalReadingAnchor() -> VerticalReadingAnchor? {
guard currentDisplayType == .verticalScroll,
collectionView.contentSize.height > 0 else { return nil }
let visibleY = max(0, collectionView.contentOffset.y)
let point = CGPoint(x: collectionView.bounds.midX, y: visibleY + 1)
guard let indexPath = collectionView.indexPathForItem(at: point) else { return nil }
return VerticalReadingAnchor(
page: indexPath.item,
offsetFromPageTop: visibleY - anchorY(forItem: indexPath.item)
)
}
public func setPagingEnabled(_ enabled: Bool) { isPagingEnabled = enabled; updatePagingState() }
/// 画笔/橡皮被选中时调用。放大层随之把单指手势让给页面画布,只保留双指拖动与捏合。
public func setDrawingToolActive(_ active: Bool) { zoomOverlayView.isDrawingToolActive = active }
public func hideToolViewIfNeeded() { if isToolViewVisible { toggleToolbars() } }
public func refreshCurrentPageIfNeeded() { pageContentView(pageNum: currentPage)?.setNeedsLayout() }
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
reusableTypes[identifier] = contentView
collectionView.register(RDPDFReaderContentCell.self, forCellWithReuseIdentifier: identifier)
}
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: IndexPath(item: pageNum, section: 0)) as! RDPDFReaderContentCell
if cell.hostedView == nil, let type = reusableTypes[identifier] { cell.hostedView = type.init() }
return cell.hostedView ?? UIView()
}
public func pageContentView(pageNum: Int) -> UIView? {
if zoomOverlayView.isPresented, let page = zoomOverlayView.page(at: pageNum) { return page }
if currentDisplayType == .pageCurl {
if let child = nativeCurlPageController?.viewControllers?.first(where: { ($0 as? RDPDFReaderPageChildViewController)?.page == pageNum }) as? RDPDFReaderPageChildViewController { return child.contentView }
if let child = nativeCurlPageController?.viewControllers?.last(where: { ($0 as? RDPDFReaderPageChildViewController)?.page == pageNum }) as? RDPDFReaderPageChildViewController { return child.contentView }
if let spread = curlContentView as? RDPDFReaderPageSpreadView { return spread.page(at: pageNum) }
return pageNum == currentPage ? curlContentView : nil
}
return (collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDPDFReaderContentCell)?.hostedView
}
/// 当前真正显示的内容页。双页卷页模式直接读取两个 child,避免用页码推算右页。
func visiblePageContentViews() -> [UIView] {
if zoomOverlayView.isPresented {
return [currentPage, currentPage + 1].compactMap { zoomOverlayView.page(at: $0) }
}
if currentDisplayType == .pageCurl {
return nativeCurlPageController?.viewControllers?
.compactMap { ($0 as? RDPDFReaderPageChildViewController)?.contentView } ?? []
}
return collectionView.visibleCells.compactMap { ($0 as? RDPDFReaderContentCell)?.hostedView }
}
var isLandscape: Bool { bounds.width > bounds.height }
/// 与 `RDEpubReaderView.pagesPerScreen` 的规则保持一致。
var pagesPerScreen: Int {
guard landscapeDualPageEnabled, isLandscape, currentDisplayType != .verticalScroll else { return 1 }
return 2
}
var usesLandscapeSpread: Bool { pagesPerScreen == 2 }
func rebuildNativeCurlController() {
nativeCurlPageController?.view.removeFromSuperview()
let options: [UIPageViewController.OptionsKey: Any]? = usesLandscapeSpread
? [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)]
: nil
let controller = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
controller.dataSource = self
controller.delegate = self
controller.isDoubleSided = usesLandscapeSpread
controller.view.frame = curlHostView.bounds
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
controller.view.backgroundColor = .clear
curlHostView.addSubview(controller.view)
nativeCurlPageController = controller
curlPanGestureRecognizer.isEnabled = false
curlPinchGestureRecognizer.isEnabled = false
}
func updateNativeCurlBookFrame() {
guard currentDisplayType == .pageCurl,
let controller = nativeCurlPageController,
let firstChild = controller.viewControllers?.first as? RDPDFReaderPageChildViewController,
let firstPage = firstChild.contentView as? RDPDFReaderPageView else { return }
// 先以宿主尺寸完成一次适配,再用实际纸张尺寸收缩卷页控制器。
controller.view.layoutIfNeeded()
let pageFrame = firstPage.fittedPageFrame(in: controller.view)
guard pageFrame.width > 0, pageFrame.height > 0 else { return }
let width = min(curlHostView.bounds.width, pageFrame.width * CGFloat(pagesPerScreen))
let height = min(curlHostView.bounds.height, pageFrame.height)
controller.view.frame = CGRect(
x: (curlHostView.bounds.width - width) / 2,
y: (curlHostView.bounds.height - height) / 2,
width: width,
height: height
).integral
controller.view.layoutIfNeeded()
}
func pageCount() -> Int { dataSource?.pageCountOfReaderView(readerView: self) ?? 0 }
func clamped(_ page: Int) -> Int? { let count = pageCount(); return count > 0 ? max(0, min(page, count - 1)) : nil }
func finishCurlTransition() { isTransitioning = false; if let pendingPage { self.pendingPage = nil; transitionToPage(pageNum: pendingPage.0, animated: pendingPage.1) } }
public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === readerPinchGestureRecognizer {
return !zoomOverlayView.isPresented && currentPage >= 0 && !isTransitioning
}
if gestureRecognizer === readerDoubleTapGestureRecognizer {
return !zoomOverlayView.isPresented && currentPage >= 0 && !isTransitioning
}
if gestureRecognizer === curlPanGestureRecognizer {
guard currentDisplayType == .pageCurl,
isPagingEnabled,
!isCurrentPageTextSelectionActive,
!isTransitioning else {
return false
}
switch curlPanInteraction {
case .pinching:
return false
default:
return true
}
}
if gestureRecognizer === curlPinchGestureRecognizer {
guard currentDisplayType == .pageCurl, curlContentView != nil, !isTransitioning else { return false }
if isCurrentPageTextSelectionActive {
(curlContentView as? RDPDFReaderPageInteractable)?.readerClearTextSelection()
}
return true
}
return super.gestureRecognizerShouldBegin(gestureRecognizer)
}
public func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
(gestureRecognizer === curlPanGestureRecognizer && otherGestureRecognizer === curlPinchGestureRecognizer)
|| (gestureRecognizer === curlPinchGestureRecognizer && otherGestureRecognizer === curlPanGestureRecognizer)
}
}
// MARK: - 独立放大层
extension RDPDFReaderView {
@objc func handleReaderPinch(_ gesture: UIPinchGestureRecognizer) {
let point = gesture.location(in: self)
switch gesture.state {
case .began:
presentZoomOverlay(focusPoint: point)
zoomOverlayView.beginPinch(at: point)
case .changed:
zoomOverlayView.updatePinch(scale: gesture.scale, at: point)
case .ended, .cancelled, .failed:
zoomOverlayView.endPinch()
default:
break
}
}
@objc func handleReaderDoubleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: self)
presentZoomOverlay(focusPoint: point)
zoomOverlayView.toggleZoom(around: point)
}
func presentZoomOverlay(focusPoint: CGPoint) {
guard !zoomOverlayView.isPresented, let page = clamped(currentPage) else { return }
let pair = spreadResolver.pair(for: page, totalPages: pageCount())
let leftIndex = usesLandscapeSpread ? pair.left : page
guard let leftPage = pageContentView(pageNum: leftIndex) else { return }
let rightPage: UIView?
if usesLandscapeSpread, let right = pair.right {
guard let visibleRightPage = pageContentView(pageNum: right) else { return }
rightPage = visibleRightPage
} else {
rightPage = nil
}
let bookFrame = currentBookFrame(using: leftPage)
zoomOverlayView.present(
leftPage: leftPage,
rightPage: rightPage,
bookSize: bookFrame.size,
backgroundColor: backgroundColor ?? .white
)
isCurrentPageZoomed = true
updatePagingState()
}
func dismissZoomOverlay() {
guard zoomOverlayView.isPresented else { return }
zoomOverlayView.dismiss()
isCurrentPageZoomed = false
updatePagingState()
}
private func currentBookFrame(using fallbackPage: UIView) -> CGRect {
if let visible = pageContentView(pageNum: currentPage) as? RDPDFReaderPageView {
let frame = visible.fittedPageFrame(in: self)
if frame.width > 0, frame.height > 0 {
return CGRect(x: 0, y: 0, width: frame.width * CGFloat(pagesPerScreen), height: frame.height)
}
}
if let page = fallbackPage as? RDPDFReaderPageView {
page.frame = bounds
page.layoutIfNeeded()
let frame = page.fittedPageFrame(in: page)
if frame.width > 0, frame.height > 0 {
return CGRect(x: 0, y: 0, width: frame.width * CGFloat(pagesPerScreen), height: frame.height)
}
}
return CGRect(origin: .zero, size: bounds.size)
}
}
/// 放大阅读态的唯一手势容器。它承载当前单页或横屏双页 spread,底层翻页视图不会
/// 参与缩放或拖动。页面仍由同一 dataSource 创建,因而图片、OCR 和标注保持一致。
final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
private struct PageLease {
let page: UIView
weak var parent: UIView?
let frame: CGRect
let autoresizingMask: UIView.AutoresizingMask
}
private let scrollView = UIScrollView()
private let canvasView = UIView()
private let doubleTapGesture = UITapGestureRecognizer()
private var pinchStartScale: CGFloat = 1
private var reportedZoomed = false
/// 双击缩小期间页面仍租借给 Overlay;必须等 UIScrollView 的缩放动画结束后再归还。
private var isAnimatingZoomOut = false
/// 捏合缩小时允许暂时小于 1 倍;不能在手势中途归还页面。
private var isInteractivePinching = false
private var pageLeases: [PageLease] = []
var onZoomStateChanged: ((Bool) -> Void)?
private(set) var isPresented = false
/// 画笔/橡皮被选中时,放大层把单指让给页面内的画布,仅保留双指拖动与捏合。
var isDrawingToolActive = false {
didSet { scrollView.panGestureRecognizer.minimumNumberOfTouches = isDrawingToolActive ? 2 : 1 }
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
scrollView.delegate = self
// 小于 1 倍用于承接“捏合缩小后松手”的回弹动画,不会作为最终阅读比例保留。
scrollView.minimumZoomScale = 0.65
scrollView.maximumZoomScale = 3
scrollView.bouncesZoom = true
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
// 画笔选中时笔迹必须立即送达画布,不能等滚动判定;未选中时画布不接收触摸,无副作用。
// 保留 canCancelContentTouches 默认值:双指拖动仍可从画布手中接管触摸,
// 画布在收到第二根手指或触摸取消时会自行丢弃未完成笔迹。
scrollView.delaysContentTouches = false
// Overlay 显示后的后续捏合由 UIScrollView 自己接收,也必须遵循缩小回弹流程。
scrollView.pinchGestureRecognizer?.addTarget(self, action: #selector(handleOverlayPinch(_:)))
addSubview(scrollView)
scrollView.addSubview(canvasView)
doubleTapGesture.addTarget(self, action: #selector(handleDoubleTap(_:)))
doubleTapGesture.numberOfTapsRequired = 2
doubleTapGesture.delegate = self
addGestureRecognizer(doubleTapGesture)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
scrollView.frame = bounds
updateInsets()
}
func present(leftPage: UIView, rightPage: UIView?, bookSize: CGSize, backgroundColor: UIColor) {
self.backgroundColor = backgroundColor
// Overlay 仍隐藏时完成所有换父视图和居中布局,避免把中间态提交到屏幕。
layoutIfNeeded()
restoreLeasedPages()
let safeSize = CGSize(width: max(1, bookSize.width), height: max(1, bookSize.height))
canvasView.frame = CGRect(origin: .zero, size: safeSize)
scrollView.contentSize = safeSize
lease(leftPage, frame: CGRect(x: 0, y: 0, width: rightPage == nil ? safeSize.width : safeSize.width / 2, height: safeSize.height))
if let rightPage {
lease(rightPage, frame: CGRect(x: safeSize.width / 2, y: 0, width: safeSize.width / 2, height: safeSize.height))
}
[leftPage, rightPage].compactMap { $0 as? RDPDFReaderPageInteractable }.forEach {
$0.readerSetInternalGesturesEnabled(false)
}
scrollView.setZoomScale(1, animated: false)
updateInsets()
// UIScrollView 在带 contentInset 的静止位置不是 .zero,而是负 inset。
// 先写 .zero 再等待 UIKit 校正会产生一帧书页贴顶的闪动。
scrollView.contentOffset = CGPoint(x: -scrollView.contentInset.left, y: -scrollView.contentInset.top)
// 容器在 present 后已进入放大态;即便用户第一步就是缩小,也要保证结束时能发出 false。
reportedZoomed = true
isPresented = true
isHidden = false
}
func dismiss() {
isAnimatingZoomOut = false
isPresented = false
isHidden = true
scrollView.setZoomScale(1, animated: false)
restoreLeasedPages()
reportedZoomed = false
}
func page(at index: Int) -> UIView? {
pageLeases.map(\.page).first(where: { $0.tag == index })
}
func beginPinch(at point: CGPoint) {
isInteractivePinching = true
pinchStartScale = scrollView.zoomScale
}
func updatePinch(scale: CGFloat, at point: CGPoint) {
guard isPresented else { return }
let targetScale = min(scrollView.maximumZoomScale, max(scrollView.minimumZoomScale, pinchStartScale * scale))
guard bounds.width > 0, bounds.height > 0 else { return }
let size = CGSize(width: bounds.width / targetScale, height: bounds.height / targetScale)
let rect = CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height)
scrollView.zoom(to: rect, animated: false)
}
func endPinch() {
isInteractivePinching = false
guard scrollView.zoomScale <= 1.01 else {
reportZoomState()
return
}
// 手指可先缩到 1 倍以下;松手后回弹至阅读器的标准比例,动画完成后再还原页面层级。
if scrollView.zoomScale < 0.999 {
isAnimatingZoomOut = true
scrollView.setZoomScale(1, animated: true)
} else {
// 恰好停在 1 倍时也延后一帧,避免在 UIScrollView 手势回调中换父视图。
DispatchQueue.main.async { [weak self] in
guard let self, self.isPresented, !self.isInteractivePinching else { return }
self.reportZoomState()
}
}
}
func toggleZoom(around point: CGPoint) {
guard isPresented else { return }
if scrollView.zoomScale > 1.01 {
// 不能在 scrollViewDidZoom 的第一帧就发出 false:那会把页面从 Overlay
// 移回翻页容器,导致缩小动画中途跳变。
isAnimatingZoomOut = true
scrollView.setZoomScale(1, animated: true)
} else {
let scale: CGFloat = 2
let size = CGSize(width: bounds.width / scale, height: bounds.height / scale)
scrollView.zoom(to: CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height), animated: true)
}
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? { canvasView }
func scrollViewDidZoom(_ scrollView: UIScrollView) {
updateInsets()
// 放大状态已经上报过;捏合/缩小动画完成前保持 Overlay 持有页面。
if !isAnimatingZoomOut, !(isInteractivePinching && scrollView.zoomScale <= 1.01) { reportZoomState() }
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
// 原始手势结束与随后触发的回弹动画都会来到这里;前者仍小于 1 倍,不能提前归还页面。
if isAnimatingZoomOut, scale < 0.999 { return }
isAnimatingZoomOut = false
// `reportZoomState()` 在此刻才触发 false,容器随后安全地恢复阅读/翻页视图。
reportZoomState()
}
private func updateInsets() {
// 缩放时 UIScrollView 会给 canvasView 加 transform`frame` 已经是缩放后的
// 视觉尺寸。若再乘 zoomScale,会在每个 pinch 回调中把 inset 多算一遍,
// UIKit 随之修正 contentOffset,慢速捏合时看起来就像书页在跳动。
let size = canvasView.bounds.size
scrollView.contentInset = UIEdgeInsets(
top: max(0, (bounds.height - size.height * scrollView.zoomScale) / 2),
left: max(0, (bounds.width - size.width * scrollView.zoomScale) / 2),
bottom: max(0, (bounds.height - size.height * scrollView.zoomScale) / 2),
right: max(0, (bounds.width - size.width * scrollView.zoomScale) / 2)
)
}
private func reportZoomState() {
let zoomed = scrollView.zoomScale > 1.01
guard zoomed != reportedZoomed else { return }
reportedZoomed = zoomed
onZoomStateChanged?(zoomed)
}
private func lease(_ page: UIView, frame: CGRect) {
guard let parent = page.superview else { return }
pageLeases.append(.init(page: page, parent: parent, frame: page.frame, autoresizingMask: page.autoresizingMask))
page.removeFromSuperview()
page.autoresizingMask = []
canvasView.addSubview(page)
page.frame = frame
page.setNeedsLayout()
page.layoutIfNeeded()
}
private func restoreLeasedPages() {
guard !pageLeases.isEmpty else { return }
for lease in pageLeases {
lease.page.removeFromSuperview()
guard let parent = lease.parent else { continue }
parent.addSubview(lease.page)
lease.page.frame = lease.frame
lease.page.autoresizingMask = lease.autoresizingMask
}
pageLeases.removeAll()
}
@objc private func handleOverlayPinch(_ gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
isInteractivePinching = true
case .ended, .cancelled, .failed:
endPinch()
default:
break
}
}
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { toggleZoom(around: gesture.location(in: self)) }
}
/// 横屏仿真翻页使用的双页容器。它仅组合两个宿主页面,不解释 PDF 内容。
final class RDPDFReaderPageSpreadView: UIView, RDPDFReaderPageInteractable {
private let leftPage: UIView
private let rightPage: UIView?
init(leftPage: UIView, rightPage: UIView?) {
self.leftPage = leftPage
self.rightPage = rightPage
super.init(frame: .zero)
addSubview(leftPage)
if let rightPage { addSubview(rightPage) }
(leftPage as? RDPDFReaderPageView)?.setSpreadContentAlignment(rightPage == nil ? .centered : .trailing)
(rightPage as? RDPDFReaderPageView)?.setSpreadContentAlignment(.leading)
wireInteractions()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
let leftWidth = rightPage == nil ? bounds.width : bounds.width / 2
leftPage.frame = CGRect(x: 0, y: 0, width: leftWidth, height: bounds.height)
rightPage?.frame = CGRect(x: leftWidth, y: 0, width: bounds.width - leftWidth, height: bounds.height)
}
func page(at index: Int) -> UIView? {
if leftPage.tag == index { return leftPage }
if rightPage?.tag == index { return rightPage }
return nil
}
var pageViews: [UIView] { [leftPage, rightPage].compactMap { $0 } }
var readerContentTapHandler: ((CGPoint) -> Void)?
var readerZoomStateChangedHandler: ((Bool) -> Void)?
var readerSelectionStateChangedHandler: ((Bool) -> Void)?
var readerIsZoomed: Bool { pages.contains { $0.readerIsZoomed } }
var readerHasActiveTextSelection: Bool { pages.contains { $0.readerHasActiveTextSelection } }
func readerSetInternalGesturesEnabled(_ enabled: Bool) { pages.forEach { $0.readerSetInternalGesturesEnabled(enabled) } }
func readerClearTextSelection() { pages.forEach { $0.readerClearTextSelection() } }
func readerFinishActiveDrawingStroke() { pages.forEach { $0.readerFinishActiveDrawingStroke() } }
func readerBeginExternalPinch(at point: CGPoint) { activePage(at: point)?.readerBeginExternalPinch(at: point) }
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) { activePage(at: point)?.readerUpdateExternalPinch(scale: scale, at: point) }
func readerEndExternalPinch() { pages.forEach { $0.readerEndExternalPinch() } }
func readerBeginExternalPan() { pages.first?.readerBeginExternalPan() }
func readerUpdateExternalPan(translation: CGPoint) { pages.first?.readerUpdateExternalPan(translation: translation) }
func readerEndExternalPan() { pages.forEach { $0.readerEndExternalPan() } }
private var pages: [RDPDFReaderPageInteractable] { [leftPage, rightPage].compactMap { $0 as? RDPDFReaderPageInteractable } }
private func activePage(at point: CGPoint) -> RDPDFReaderPageInteractable? {
guard let rightPage, point.x >= bounds.midX else { return leftPage as? RDPDFReaderPageInteractable }
return rightPage as? RDPDFReaderPageInteractable
}
private func wireInteractions() {
[leftPage, rightPage].compactMap { $0 }.forEach { page in
guard let interactable = page as? RDPDFReaderPageInteractable else { return }
interactable.readerContentTapHandler = { [weak self, weak page] point in
guard let self, let page else { return }
self.readerContentTapHandler?(page.convert(point, to: self))
}
interactable.readerZoomStateChangedHandler = { [weak self] _ in self?.readerZoomStateChangedHandler?(self?.readerIsZoomed ?? false) }
interactable.readerSelectionStateChangedHandler = { [weak self] _ in self?.readerSelectionStateChangedHandler?(self?.readerHasActiveTextSelection ?? false) }
}
}
}