宿主的点读书场景需要在 PDF 页面上叠加可点击热区(音频/视频/网页等),并支持 试读限制与阅读进度、书签的本地保存,这些能力此前 RDPDFReaderView 均不提供, 只能由宿主在阅读器之外自行叠加视图,无法跟随 SDK 的缩放、翻页和主题渲染同步。 新增 RDPDFReaderPageInteraction 描述热区(位置、图标、边框/填充样式、点击态、 闪烁与播放边框规则、内嵌视频),由新增的 RDPDFInteractionHotspotView 负责绘制, SVG 图标经 RDPDFReaderSVGIconLoader(基于 SDWebImage/SDWebImageSVGCoder)异步 解码后回填,避免阻塞主线程;点击事件通过 interactionHandler 回传给宿主,媒体 播放、路由等业务仍留在宿主侧。底部工具栏新增点读提示与连播按钮,状态由宿主驱动。 试读墙对齐 EPUB 侧 RDEPUBReaderTrialPolicy 的设计:以页为限制单位,可读页数在 配置了 trialPolicy 且宿主经 delegate 提供墙视图时生效,原始总页数、缩略图、目录 换算不受影响,只有传给 RDPDFReaderView 的有效页数会追加一页试读墙;翻页、目录 跳转、大纲的上界都统一收敛到可读范围,避免用户绕过热区跳转或朗读高亮跳进未授权页。 阅读进度与书签复用 RDPDFReaderPersistenceStore 已有的标注状态目录,新增 reader-state.json 承载,不需要宿主额外提供数据库表;RDPDFReaderPersistenceStore 同时实现 RDPDFReaderPersistence 协议对接这部分读写。
260 lines
12 KiB
Swift
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()
|
|
}
|
|
}
|