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) } } } }