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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user