PDF 阅读器新增点读热区、试读墙与阅读进度/书签持久化

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

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

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

阅读进度与书签复用 RDPDFReaderPersistenceStore 已有的标注状态目录,新增
reader-state.json 承载,不需要宿主额外提供数据库表;RDPDFReaderPersistenceStore
同时实现 RDPDFReaderPersistence 协议对接这部分读写。
This commit is contained in:
shenlei
2026-08-14 12:13:04 +09:00
parent 278072aca0
commit e59bad9abf
11 changed files with 1064 additions and 16 deletions
@@ -0,0 +1,132 @@
import UIKit
/// PDF
///
/// EPUB `RDEPUBReaderTrialPolicy` """"
/// PDF pageMap
public struct RDPDFReaderTrialPolicy: Equatable {
/// 0..<readablePageCount
/// nil <= 0
public var readablePageCount: Int?
public init(readablePageCount: Int? = nil) {
self.readablePageCount = readablePageCount
}
}
///
///
/// EPUB `book.totalPages`
/// `RDPDFReaderView` +1
extension RDPDFReaderViewController {
/// 访 delegate
var trialWallView: UIView? {
if let cached = cachedTrialWallView {
return cached
}
guard configuration.trialPolicy != nil else { return nil }
let view = delegate?.pdfReaderTrialWallView(self)
cachedTrialWallView = view
return view
}
/// 宿
var isTrialWallEnabled: Bool {
configuration.trialPolicy != nil && trialWallView != nil
}
///
var readableContentPageCount: Int {
guard let policy = configuration.trialPolicy,
let readablePageCount = policy.readablePageCount,
readablePageCount > 0 else {
return book.totalPages
}
return min(readablePageCount, book.totalPages)
}
/// 0-based nil
var trialWallPageIndex: Int? {
guard isTrialWallEnabled else { return nil }
//
return readableContentPageCount
}
/// RDPDFReaderView 使 + 1
var effectivePageCount: Int {
guard isTrialWallEnabled else { return book.totalPages }
return readableContentPageCount + 1
}
///
func isTrialWallPage(_ pageIndex: Int) -> Bool {
trialWallPageIndex == pageIndex
}
///
func makeTrialWallPageView(reusableView: UIView?) -> UIView {
let container = (reusableView as? RDPDFTrialWallContainerView) ?? RDPDFTrialWallContainerView()
container.setWallView(trialWallView)
return container
}
}
/// 宿
///
/// 宿 View View
/// ""
final class RDPDFTrialWallContainerView: UIView, UIGestureRecognizerDelegate {
private weak var wallView: UIView?
/// `RDPDFReaderPageView.readerContentTapHandler`
/// UIView
/// `RDPDFReaderPageInteractable`
///
var contentTapHandler: ((CGPoint) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.delegate = self
addGestureRecognizer(tap)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func setWallView(_ view: UIView?) {
// 使 View
if wallView === view, view?.superview === self {
return
}
wallView?.removeFromSuperview()
guard let view else { return }
view.translatesAutoresizingMaskIntoConstraints = false
addSubview(view)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: leadingAnchor),
view.trailingAnchor.constraint(equalTo: trailingAnchor),
view.topAnchor.constraint(equalTo: topAnchor),
view.bottomAnchor.constraint(equalTo: bottomAnchor)
])
wallView = view
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
contentTapHandler?(gesture.location(in: self))
}
/// /
/// 沿 touch.view self `UIControl`
///
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
var view = touch.view
while let current = view, current !== self {
if current is UIControl { return false }
view = current.superview
}
return true
}
}