Files
ReadViewSDK/Sources/RDPDFReaderView/ReaderView/RDPDFReaderView+Paging.swift
T
shenlei e59bad9abf PDF 阅读器新增点读热区、试读墙与阅读进度/书签持久化
宿主的点读书场景需要在 PDF 页面上叠加可点击热区(音频/视频/网页等),并支持
试读限制与阅读进度、书签的本地保存,这些能力此前 RDPDFReaderView 均不提供,
只能由宿主在阅读器之外自行叠加视图,无法跟随 SDK 的缩放、翻页和主题渲染同步。

新增 RDPDFReaderPageInteraction 描述热区(位置、图标、边框/填充样式、点击态、
闪烁与播放边框规则、内嵌视频),由新增的 RDPDFInteractionHotspotView 负责绘制,
SVG 图标经 RDPDFReaderSVGIconLoader(基于 SDWebImage/SDWebImageSVGCoder)异步
解码后回填,避免阻塞主线程;点击事件通过 interactionHandler 回传给宿主,媒体
播放、路由等业务仍留在宿主侧。底部工具栏新增点读提示与连播按钮,状态由宿主驱动。

试读墙对齐 EPUB 侧 RDEPUBReaderTrialPolicy 的设计:以页为限制单位,可读页数在
配置了 trialPolicy 且宿主经 delegate 提供墙视图时生效,原始总页数、缩略图、目录
换算不受影响,只有传给 RDPDFReaderView 的有效页数会追加一页试读墙;翻页、目录
跳转、大纲的上界都统一收敛到可读范围,避免用户绕过热区跳转或朗读高亮跳进未授权页。

阅读进度与书签复用 RDPDFReaderPersistenceStore 已有的标注状态目录,新增
reader-state.json 承载,不需要宿主额外提供数据库表;RDPDFReaderPersistenceStore
同时实现 RDPDFReaderPersistence 协议对接这部分读写。
2026-08-14 12:13:04 +09:00

260 lines
12 KiB
Swift

import UIKit
/// 页面创建、缩放状态和点击分区。与 EPUB 的 `RDEpubReaderView+ContentAccess`
/// 及 Paging 模块保持同一职责边界。
extension RDPDFReaderView {
func makePageView(for page: Int) -> UIView {
if usesLandscapeSpread {
let left = dataSource?.pageContentView(readerView: self, pageNum: page) ?? UIView()
left.tag = page
let next = page + 1
let right: UIView?
if next < pageCount() {
let view = dataSource?.pageContentView(readerView: self, pageNum: next) ?? UIView()
view.tag = next
right = view
} else {
right = nil
}
let spread = RDPDFReaderPageSpreadView(leftPage: left, rightPage: right)
spread.tag = page
configureInteraction(for: spread)
return spread
}
let view = dataSource?.pageContentView(readerView: self, pageNum: page) ?? UIView()
view.tag = page
configureInteraction(for: view)
return view
}
func configureInteraction(for view: UIView) {
// 试读墙页是普通 UIView,不需要 RDPDFReaderPageInteractable 带的
// 缩放/文本选中能力,只需要点击→显隐工具条,单独走一条更轻量的路径。
if let wall = view as? RDPDFTrialWallContainerView {
wall.contentTapHandler = { [weak self, weak view] point in
guard let self, let view else { return }
self.handleContentTap(view.convert(point, to: self))
}
return
}
guard let page = view as? RDPDFReaderPageInteractable else { return }
// 所有阅读模式统一由 ReaderView 决定何时进入独立放大层;页面本身不再
// 各自接管 pinch/double-tap,避免横屏左右页出现两套缩放坐标。
page.readerSetInternalGesturesEnabled(false)
page.readerContentTapHandler = { [weak self, weak view] point in
guard let self, let view else { return }
self.handleContentTap(view.convert(point, to: self))
}
page.readerZoomStateChangedHandler = { [weak self, weak view] zoomed in
guard let self, view?.tag == self.currentPage else { return }
self.isCurrentPageZoomed = zoomed
self.updatePagingState()
}
page.readerSelectionStateChangedHandler = { [weak self, weak view] active in
guard let self, view?.tag == self.currentPage else { return }
self.isCurrentPageTextSelectionActive = active
self.updatePagingState()
}
}
func synchronizePageState() {
isCurrentPageZoomed = (pageContentView(pageNum: currentPage) as? RDPDFReaderPageInteractable)?.readerIsZoomed ?? false
isCurrentPageTextSelectionActive = (pageContentView(pageNum: currentPage) as? RDPDFReaderPageInteractable)?.readerHasActiveTextSelection ?? false
updatePagingState()
}
func updatePagingState() {
let enabled = isPagingEnabled && !isCurrentPageZoomed && !isCurrentPageTextSelectionActive
collectionView.isScrollEnabled = enabled
collectionView.panGestureRecognizer.isEnabled = enabled
// 仿真翻页由 UIPageViewController 自己的手势驱动,不能只关闭外层
// collectionView。画笔模式调用 `setPagingEnabled(false)` 后一并禁用它,
// 这样单指始终交给内容页里的 DrawingCanvas,不会卷页。
nativeCurlPageController?.gestureRecognizers.forEach { $0.isEnabled = enabled }
curlPanGestureRecognizer.isEnabled = nativeCurlPageController == nil && currentDisplayType == .pageCurl
&& isPagingEnabled
&& !isCurrentPageTextSelectionActive
}
func transitionCurl(to page: Int, animated: Bool) {
guard let controller = nativeCurlPageController else { return }
let left = RDPDFReaderPageChildViewController(contentView: makeIndividualPageView(for: page), page: page)
var children = [left]
if usesLandscapeSpread {
let rightPage = page + 1
let rightContent = rightPage < pageCount() ? makeIndividualPageView(for: rightPage) : UIView()
children.append(RDPDFReaderPageChildViewController(contentView: rightContent, page: rightPage))
}
let direction: UIPageViewController.NavigationDirection = page >= currentPage ? .forward : .reverse
controller.setViewControllers(children, direction: direction, animated: animated) { [weak self] _ in
self?.updateNativeCurlBookFrame()
}
currentPage = page
if !animated {
updateNativeCurlBookFrame()
}
}
func makeIndividualPageView(for page: Int) -> UIView {
let view = dataSource?.pageContentView(readerView: self, pageNum: page) ?? UIView()
view.tag = page
configureSpreadAlignment(for: view, page: page)
configureInteraction(for: view)
return view
}
func configureSpreadAlignment(for view: UIView, page: Int) {
if let pdfPage = view as? RDPDFReaderPageView {
if usesLandscapeSpread {
// 原生双页卷页以偶数页为左页、奇数页为右页。
// 左页内容贴右边,右页内容贴左边,两张 PDF 的纸张边缘直接相接。
pdfPage.setSpreadContentAlignment(page.isMultiple(of: 2) ? .trailing : .leading)
} else {
pdfPage.setSpreadContentAlignment(.centered)
}
}
}
@objc func handleCurlPan(_ gesture: UIPanGestureRecognizer) {
guard let page = curlContentView as? RDPDFReaderPageInteractable else { return }
switch gesture.state {
case .began:
guard !page.readerHasActiveTextSelection else {
curlPanInteraction = .idle
return
}
if page.readerIsZoomed {
curlPanInteraction = .contentPan
page.readerBeginExternalPan()
} else {
curlPanInteraction = .pageTurn(startPage: currentPage)
}
case .changed:
guard !page.readerHasActiveTextSelection else {
curlPanInteraction = .idle
return
}
if case .contentPan = curlPanInteraction {
page.readerUpdateExternalPan(translation: gesture.translation(in: curlHostView))
}
case .ended:
guard !page.readerHasActiveTextSelection else {
curlPanInteraction = .idle
return
}
finishCurlPan(gesture, page: page, cancelled: false)
case .cancelled, .failed:
finishCurlPan(gesture, page: page, cancelled: true)
default:
break
}
}
private func finishCurlPan(
_ gesture: UIPanGestureRecognizer,
page: RDPDFReaderPageInteractable,
cancelled: Bool
) {
let interaction = curlPanInteraction
curlPanInteraction = .idle
switch interaction {
case .contentPan:
page.readerEndExternalPan()
case let .pageTurn(startPage):
guard !cancelled, currentPage == startPage else { return }
let translation = gesture.translation(in: curlHostView)
let velocity = gesture.velocity(in: curlHostView)
guard abs(translation.x) > abs(translation.y) else { return }
let crossedDistance = abs(translation.x) >= max(44, curlHostView.bounds.width * 0.12)
let crossedVelocity = abs(velocity.x) >= 450
guard crossedDistance || crossedVelocity else { return }
let direction = abs(translation.x) >= 4 ? translation.x : velocity.x
let step = usesLandscapeSpread ? 2 : 1
transitionToPage(pageNum: startPage + (direction < 0 ? step : -step), animated: true)
case .idle:
break
case .pinching:
// 捏合拥有当前触摸序列,pan 的结束事件不能改变它的状态。
curlPanInteraction = .pinching
}
}
@objc func handleCurlPinch(_ gesture: UIPinchGestureRecognizer) {
guard currentDisplayType == .pageCurl,
let contentView = curlContentView,
let page = contentView as? RDPDFReaderPageInteractable else { return }
let point = gesture.location(in: contentView)
switch gesture.state {
case .began:
if case .contentPan = curlPanInteraction {
page.readerEndExternalPan()
}
// 第二根手指到来后,捏合优先,已产生的单指翻页候选立即失效。
curlPanInteraction = .pinching
page.readerBeginExternalPinch(at: point)
case .changed:
if case .pinching = curlPanInteraction {
page.readerUpdateExternalPinch(scale: gesture.scale, at: point)
}
case .ended, .cancelled, .failed:
page.readerEndExternalPinch()
// 重置 recognizer,确保捏合结束后仍留在屏幕上的单根手指不会在
// 同一触摸序列中重新变成翻页或内容拖动。
curlPanGestureRecognizer.isEnabled = false
curlPanInteraction = .idle
curlPanGestureRecognizer.isEnabled = currentDisplayType == .pageCurl && isPagingEnabled
default:
break
}
}
func handleContentTap(_ point: CGPoint) {
if isCurrentPageTextSelectionActive {
(curlContentView as? RDPDFReaderPageInteractable)?.readerClearTextSelection()
return
}
// EPUB 的仿真模式由 UIPageViewController 接管翻页;内容点击只负责显隐工具栏。
// PDF 的自定义仿真容器保持相同语义,避免边缘点击绕开横向翻页手势。
guard currentDisplayType != .pageCurl else {
toggleToolbars()
return
}
// 手机横屏单页是连续竖滑阅读,翻页由滚动完成;点击不跳页,只显隐工具栏。
guard !usesWidthFitVerticalScroll else {
toggleToolbars()
return
}
if isToolViewVisible {
toggleToolbars()
} else if point.x < bounds.width / 3 {
transitionToPage(pageNum: currentPage - (usesLandscapeSpread ? 2 : 1), animated: true)
} else if point.x > bounds.width * 2 / 3 {
transitionToPage(pageNum: currentPage + (usesLandscapeSpread ? 2 : 1), animated: true)
} else {
toggleToolbars()
}
}
}
extension RDPDFReaderView: UIPageViewControllerDataSource, UIPageViewControllerDelegate {
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let child = viewController as? RDPDFReaderPageChildViewController else { return nil }
let page = child.page - 1
guard page >= 0 else { return nil }
return RDPDFReaderPageChildViewController(contentView: makeIndividualPageView(for: page), page: page)
}
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let child = viewController as? RDPDFReaderPageChildViewController else { return nil }
let page = child.page + 1
guard page < pageCount() else { return nil }
return RDPDFReaderPageChildViewController(contentView: makeIndividualPageView(for: page), page: page)
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
guard completed, let child = pageViewController.viewControllers?.first as? RDPDFReaderPageChildViewController else { return }
currentPage = child.page
updateNativeCurlBookFrame()
}
}