Files
ReadViewSDK/Sources/RDPDFReaderView/Sources/RDPDFReaderTrial.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

133 lines
5.3 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
/// 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
}
}