将 PDF 成品阅读控制器下沉为 RDPDFReaderView SDK,并修复手机横屏单页连续竖滑
SDK 化收尾: - 新增 RDPDFReaderView 本地 pod(阅读容器、成品控制器、划线/注释、画笔、 目录/设置面板、主题与持久化契约),Demo 精简为 PDFKit 页面提供者 + 持久化宿主 - Podfile/工程接入 RDPDFReaderView 与 SnapKit,示例书改用 PDF 阅读器示例文件 手机横屏单页竖滑修复: - cell 高度改为宽度适配后的纸张高度(sizeForItemAt + 宿主按页提供纵横比), 纵向超出一屏的内容由外层 collectionView 连续滚动承接,不再被裁掉 - 页面内部不再开启同向嵌套的兜底竖向拖动,修复外层滚动被吞、无法滚到下一页 - 修复 applyContentSize 先评估拖动开关后写 contentSize 的顺序缺陷 - 页码按视口中心命中 cell 计算,补 didEndDragging 兜底;旋转重锚点与 transitionToPage 改用真实布局位置 - 横竖屏同为竖滑时也重建 cell 并刷新全部可见页的适配配置 - 横屏单页点击不再跳页,只显隐工具栏;翻页交给连续竖滑 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a17164d1a9
commit
1422461224
@@ -0,0 +1,16 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "RDPDFReaderView"
|
||||
s.module_name = "RDPDFReaderView"
|
||||
s.version = "0.1.0"
|
||||
s.summary = "Independent UIKit PDF reader interaction engine"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "https://example.invalid/RDPDFReaderView"
|
||||
s.author = { "readoor" => "ios@touchread.com" }
|
||||
s.source = { :path => "." }
|
||||
s.license = "MIT"
|
||||
s.source_files = "{Sources,ReaderView}/**/*.swift"
|
||||
s.dependency "SnapKit", "~> 5.7"
|
||||
s.frameworks = "Vision", "CoreImage"
|
||||
s.requires_arc = true
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
# RDPDFReaderView
|
||||
|
||||
独立的 UIKit PDF 阅读交互 SDK。当前第一阶段只包含与业务无关的部分:分页、双指缩放、双击缩放、放大后拖动,以及画笔模式下的手势隔离。
|
||||
|
||||
主工程的下载、解密、缓存、链接、标注和持久化仍由现有 PDF Feature 管理;后续通过适配层逐步迁移,避免把业务问题和手势问题混在一起。
|
||||
|
||||
## 调试入口
|
||||
|
||||
在任意测试宿主中 push `RDPDFReaderDebugViewController()`:
|
||||
|
||||
```swift
|
||||
navigationController?.pushViewController(RDPDFReaderDebugViewController(), animated: true)
|
||||
```
|
||||
|
||||
验证顺序:
|
||||
|
||||
1. 双指捏合能连续放大和缩小。
|
||||
2. 双击能在适配尺寸与两倍缩放之间切换。
|
||||
3. 放大后单指只能拖动内容,不能翻页。
|
||||
4. 切到“画笔模式”后,单指不再翻页,双指仍能缩放和拖动。
|
||||
|
||||
如果这个独立页面工作正常,而主阅读页仍失效,问题就能确定在主工程的页面层/覆盖层,而不是缩放内核。
|
||||
|
||||
## 图片型 PDF 的文本、复制与标注
|
||||
|
||||
主程序即使只能提供每页图片,也可以接入文字功能:
|
||||
|
||||
1. 若主程序已有 PDF 解析结果,在 `RDPDFReaderPageDescriptor.textRuns` 中传入文字和相对图片的 `0...1` 坐标;这条路径提供最准确的复制和高亮。
|
||||
2. 若没有文字坐标,使用 `RDPDFReaderImageTextRecognizer` 对当前页图片按需 OCR,再把结果赋给 `RDPDFReaderImageTextLayerView.textRuns`。
|
||||
3. OCR 也不可用时,文字层会提供区域框选;区域标注只显示“高亮、注释”,不会错误地提供复制。
|
||||
|
||||
高亮与注释统一使用 `RDPDFReaderAnnotation`,坐标同样是相对图片的 `0...1` 比例,缩放、旋转或重新渲染页面后仍能对齐。使用 `RDPDFReaderPersistenceStore` 保存时,请传入宿主稳定的书籍 ID(可附账号和内容版本)对应的目录,并对 `addAnnotation`、`updateAnnotation`、`deleteAnnotation` 的 `throws` 结果做错误提示;文件损坏或版本不兼容时 SDK 会拒绝覆盖原数据。
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
/// PDF 的跨页解析规则,与 EPUB 阅读器的无封面分支保持一致。
|
||||
final class RDPDFReaderSpreadResolver {
|
||||
func pair(for page: Int, totalPages: Int) -> (left: Int, right: Int?) {
|
||||
let left = max(0, page / 2 * 2)
|
||||
return (left, left + 1 < totalPages ? left + 1 : nil)
|
||||
}
|
||||
|
||||
func adjacent(from page: Int, totalPages: Int, forward: Bool) -> Int? {
|
||||
let current = pair(for: page, totalPages: totalPages)
|
||||
if forward {
|
||||
let next = (current.right ?? current.left) + 1
|
||||
return next < totalPages ? next : nil
|
||||
}
|
||||
guard current.left > 0 else { return nil }
|
||||
return pair(for: current.left - 1, totalPages: totalPages).left
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页运行时状态的归属点,避免阅读容器同时承担布局和转场状态。
|
||||
final class RDPDFReaderPagingController: NSObject {
|
||||
var isTransitioning = false
|
||||
var pendingTransition: (page: Int, animated: Bool)?
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import UIKit
|
||||
|
||||
final class RDPDFReaderContentCell: UICollectionViewCell {
|
||||
var hostedView: UIView? {
|
||||
didSet {
|
||||
oldValue?.removeFromSuperview()
|
||||
guard let hostedView else { return }
|
||||
contentView.addSubview(hostedView)
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import UIKit
|
||||
|
||||
final class RDPDFReaderPageChildViewController: UIViewController {
|
||||
let contentView: UIView
|
||||
let page: Int
|
||||
|
||||
init(contentView: UIView, page: Int) {
|
||||
self.contentView = contentView
|
||||
self.page = page
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func loadView() {
|
||||
// 页面控制器只拥有稳定的外壳;实际 PDF 内容页可在放大时临时迁移到
|
||||
// Zoom Overlay,退出时再回到此处,而不会破坏 UIPageViewController 的层级。
|
||||
let container = UIView()
|
||||
container.backgroundColor = .clear
|
||||
container.addSubview(contentView)
|
||||
contentView.frame = container.bounds
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view = container
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import UIKit
|
||||
|
||||
extension RDPDFReaderView: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
pageCount()
|
||||
}
|
||||
|
||||
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let view = dataSource?.pageContentView(readerView: self, pageNum: indexPath.item) ?? UIView()
|
||||
view.tag = indexPath.item
|
||||
configureSpreadAlignment(for: view, page: indexPath.item)
|
||||
configureInteraction(for: view)
|
||||
if let cell = view.superview?.superview as? RDPDFReaderContentCell { return cell }
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "fallback", for: indexPath) as! RDPDFReaderContentCell
|
||||
cell.hostedView = view
|
||||
return cell
|
||||
}
|
||||
|
||||
public func collectionView(
|
||||
_ collectionView: UICollectionView,
|
||||
layout collectionViewLayout: UICollectionViewLayout,
|
||||
sizeForItemAt indexPath: IndexPath
|
||||
) -> CGSize {
|
||||
// 手机横屏竖滑:cell 高度=宽度适配后的纸张高度,超出一屏的内容由
|
||||
// collectionView 自身的连续滚动承接;页面未加载时先按一屏高占位。
|
||||
if usesWidthFitVerticalScroll,
|
||||
let height = verticalScrollWidthFitPageHeightProvider?(indexPath.item, bounds.width),
|
||||
height > 0 {
|
||||
return CGSize(width: bounds.width, height: height)
|
||||
}
|
||||
return usesLandscapeSpread
|
||||
? CGSize(width: bounds.width / 2, height: bounds.height)
|
||||
: bounds.size
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
updateCurrentPageAfterScroll(scrollView)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
// 竖滑是连续滚动(isPagingEnabled = false),轻推后直接停下不会触发
|
||||
// 减速回调;此处兜底,否则页码停留在旧值。
|
||||
if !decelerate { updateCurrentPageAfterScroll(scrollView) }
|
||||
}
|
||||
|
||||
private func updateCurrentPageAfterScroll(_ scrollView: UIScrollView) {
|
||||
guard currentDisplayType != .pageCurl else { return }
|
||||
if currentDisplayType == .verticalScroll {
|
||||
// 宽度适配下各 cell 高度不同,页码取视口中心命中的 cell。
|
||||
// 顶部/底部回弹时中心点会越出内容范围,钳制回内容内再取 cell。
|
||||
let centerY = scrollView.contentOffset.y + scrollView.bounds.height / 2
|
||||
let center = CGPoint(
|
||||
x: scrollView.bounds.width / 2,
|
||||
y: min(max(0, centerY), max(0, collectionView.contentSize.height - 1))
|
||||
)
|
||||
if let indexPath = collectionView.indexPathForItem(at: center) {
|
||||
currentPage = indexPath.item
|
||||
} else if let first = collectionView.indexPathsForVisibleItems.min(by: { $0.item < $1.item }) {
|
||||
currentPage = first.item
|
||||
}
|
||||
return
|
||||
}
|
||||
let value = scrollView.contentOffset.x / max(bounds.width, 1)
|
||||
currentPage = Int(value.rounded()) * (usesLandscapeSpread ? 2 : 1)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,23 @@ import UIKit
|
||||
/// 及 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)
|
||||
@@ -12,7 +29,9 @@ extension RDPDFReaderView {
|
||||
|
||||
func configureInteraction(for view: UIView) {
|
||||
guard let page = view as? RDPDFReaderPageInteractable else { return }
|
||||
page.readerSetInternalGesturesEnabled(currentDisplayType != .pageCurl)
|
||||
// 所有阅读模式统一由 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))
|
||||
@@ -38,49 +57,52 @@ extension RDPDFReaderView {
|
||||
func updatePagingState() {
|
||||
let enabled = isPagingEnabled && !isCurrentPageZoomed && !isCurrentPageTextSelectionActive
|
||||
collectionView.isScrollEnabled = enabled
|
||||
curlPanGestureRecognizer.isEnabled = currentDisplayType == .pageCurl
|
||||
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) {
|
||||
if isTransitioning {
|
||||
pendingPage = (page, animated)
|
||||
return
|
||||
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))
|
||||
}
|
||||
if page == currentPage, curlContentView != nil {
|
||||
synchronizePageState()
|
||||
return
|
||||
let direction: UIPageViewController.NavigationDirection = page >= currentPage ? .forward : .reverse
|
||||
controller.setViewControllers(children, direction: direction, animated: animated) { [weak self] _ in
|
||||
self?.updateNativeCurlBookFrame()
|
||||
}
|
||||
|
||||
let previousPage = currentPage
|
||||
let nextView = makePageView(for: page)
|
||||
nextView.frame = curlHostView.bounds
|
||||
nextView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
let installPage = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.curlContentView?.removeFromSuperview()
|
||||
self.curlHostView.addSubview(nextView)
|
||||
self.curlContentView = nextView
|
||||
self.currentPage = page
|
||||
currentPage = page
|
||||
if !animated {
|
||||
updateNativeCurlBookFrame()
|
||||
}
|
||||
}
|
||||
|
||||
guard animated, curlContentView != nil, previousPage != page else {
|
||||
installPage()
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
isTransitioning = true
|
||||
curlPanInteraction = .idle
|
||||
let animation: UIView.AnimationOptions = page > previousPage ? .transitionCurlUp : .transitionCurlDown
|
||||
UIView.transition(
|
||||
with: curlHostView,
|
||||
duration: 0.38,
|
||||
options: [animation, .allowAnimatedContent],
|
||||
animations: installPage
|
||||
) { [weak self] _ in
|
||||
self?.finishCurlTransition()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,12 +155,13 @@ extension RDPDFReaderView {
|
||||
guard !cancelled, currentPage == startPage else { return }
|
||||
let translation = gesture.translation(in: curlHostView)
|
||||
let velocity = gesture.velocity(in: curlHostView)
|
||||
guard abs(translation.y) > abs(translation.x) else { return }
|
||||
let crossedDistance = abs(translation.y) >= max(44, curlHostView.bounds.height * 0.12)
|
||||
let crossedVelocity = abs(velocity.y) >= 450
|
||||
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.y) >= 4 ? translation.y : velocity.y
|
||||
transitionToPage(pageNum: startPage + (direction < 0 ? 1 : -1), animated: true)
|
||||
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:
|
||||
@@ -181,14 +204,47 @@ extension RDPDFReaderView {
|
||||
(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.y < bounds.height / 3 {
|
||||
transitionToPage(pageNum: currentPage - 1, animated: true)
|
||||
} else if point.y > bounds.height * 2 / 3 {
|
||||
transitionToPage(pageNum: currentPage + 1, animated: true)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 阅读器 Chrome 的安装、尺寸更新与显示动画。
|
||||
extension RDPDFReaderView {
|
||||
enum ToolViewPosition { case top, bottom }
|
||||
|
||||
func toggleToolbars() {
|
||||
isToolViewVisible.toggle()
|
||||
if isToolViewVisible {
|
||||
if let topToolView { showToolView(topToolView, position: .top) }
|
||||
if let bottomToolView { showToolView(bottomToolView, position: .bottom) }
|
||||
} else {
|
||||
if let topToolView { hideToolView(topToolView, position: .top) }
|
||||
if let bottomToolView { hideToolView(bottomToolView, position: .bottom) }
|
||||
}
|
||||
delegate?.toolViewVisibilityChanged?(readerView: self, isVisible: isToolViewVisible)
|
||||
}
|
||||
|
||||
func showToolView(_ toolView: UIView, position: ToolViewPosition) {
|
||||
installToolViewIfNeeded(toolView, position: position)
|
||||
layoutIfNeeded()
|
||||
toolView.transform = CGAffineTransform(translationX: 0, y: position == .top ? -toolView.bounds.height : toolView.bounds.height)
|
||||
UIView.animate(withDuration: 0.25) { toolView.transform = .identity }
|
||||
}
|
||||
|
||||
func hideToolView(_ toolView: UIView, position: ToolViewPosition) {
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
toolView.transform = CGAffineTransform(translationX: 0, y: position == .top ? -toolView.bounds.height : toolView.bounds.height)
|
||||
}) { _ in
|
||||
toolView.removeFromSuperview()
|
||||
toolView.transform = .identity
|
||||
}
|
||||
}
|
||||
|
||||
func installToolViewIfNeeded(_ toolView: UIView, position: ToolViewPosition) {
|
||||
guard toolView.superview !== self else { return }
|
||||
toolView.removeFromSuperview()
|
||||
addSubview(toolView)
|
||||
|
||||
let heightConstraint: Constraint
|
||||
switch position {
|
||||
case .top:
|
||||
topToolViewHeightConstraint?.deactivate()
|
||||
var height: Constraint!
|
||||
toolView.snp.makeConstraints { make in
|
||||
make.leading.trailing.top.equalToSuperview()
|
||||
height = make.height.equalTo(safeAreaInsets.top + 52).constraint
|
||||
}
|
||||
heightConstraint = height
|
||||
topToolViewHeightConstraint = heightConstraint
|
||||
case .bottom:
|
||||
bottomToolViewHeightConstraint?.deactivate()
|
||||
var height: Constraint!
|
||||
toolView.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
height = make.height.equalTo(safeAreaInsets.bottom + 52).constraint
|
||||
}
|
||||
heightConstraint = height
|
||||
bottomToolViewHeightConstraint = heightConstraint
|
||||
}
|
||||
}
|
||||
|
||||
func updateToolViewHeightConstraintsIfNeeded() {
|
||||
topToolViewHeightConstraint?.update(offset: safeAreaInsets.top + 52)
|
||||
bottomToolViewHeightConstraint?.update(offset: safeAreaInsets.bottom + 52)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// PDF 标注的注释输入页。文字和区域标注共用同一编辑体验,
|
||||
/// UI 与 EPUB 的 `RDEPUBAnnotationEditorViewController` 保持一致。
|
||||
@@ -21,7 +22,7 @@ public final class RDPDFReaderAnnotationEditorViewController: UIViewController,
|
||||
self.theme = theme
|
||||
self.onSave = onSave
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = "添加注释"
|
||||
title = "写注释"
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
@@ -38,14 +39,15 @@ public final class RDPDFReaderAnnotationEditorViewController: UIViewController,
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "保存", style: .done, target: self, action: #selector(saveAction))
|
||||
navigationItem.rightBarButtonItem?.accessibilityIdentifier = "pdf.reader.annotation.save"
|
||||
navigationController?.navigationBar.tintColor = .systemBlue
|
||||
navigationController?.navigationBar.titleTextAttributes = [
|
||||
.foregroundColor: theme?.toolControlTextColor ?? .label,
|
||||
.font: UIFont.systemFont(ofSize: 17, weight: .semibold)
|
||||
]
|
||||
|
||||
let quoteCard = UIView()
|
||||
let rail = UIView()
|
||||
let quoteLabel = UILabel()
|
||||
let textCard = UIView()
|
||||
[quoteCard, rail, quoteLabel, textCard, textView, countLabel].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
view.addSubview(quoteCard)
|
||||
quoteCard.addSubview(rail)
|
||||
quoteCard.addSubview(quoteLabel)
|
||||
@@ -83,32 +85,34 @@ public final class RDPDFReaderAnnotationEditorViewController: UIViewController,
|
||||
countLabel.textColor = .secondaryLabel
|
||||
countLabel.textAlignment = .right
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
quoteCard.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
|
||||
quoteCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||
quoteCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||
rail.leadingAnchor.constraint(equalTo: quoteCard.leadingAnchor, constant: 16),
|
||||
rail.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
|
||||
rail.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
|
||||
rail.widthAnchor.constraint(equalToConstant: 4),
|
||||
quoteLabel.leadingAnchor.constraint(equalTo: quoteCard.leadingAnchor, constant: 28),
|
||||
quoteLabel.trailingAnchor.constraint(equalTo: quoteCard.trailingAnchor, constant: -16),
|
||||
quoteLabel.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
|
||||
quoteLabel.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
|
||||
|
||||
textCard.topAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: 20),
|
||||
textCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||
textCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||
textCard.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -20),
|
||||
textCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 240),
|
||||
textView.topAnchor.constraint(equalTo: textCard.topAnchor, constant: 16),
|
||||
textView.leadingAnchor.constraint(equalTo: textCard.leadingAnchor, constant: 16),
|
||||
textView.trailingAnchor.constraint(equalTo: textCard.trailingAnchor, constant: -16),
|
||||
textView.bottomAnchor.constraint(equalTo: countLabel.topAnchor, constant: -12),
|
||||
countLabel.leadingAnchor.constraint(equalTo: textCard.leadingAnchor, constant: 16),
|
||||
countLabel.trailingAnchor.constraint(equalTo: textCard.trailingAnchor, constant: -16),
|
||||
countLabel.bottomAnchor.constraint(equalTo: textCard.bottomAnchor, constant: -12)
|
||||
])
|
||||
quoteCard.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(20)
|
||||
make.horizontalEdges.equalToSuperview().inset(20)
|
||||
}
|
||||
rail.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.verticalEdges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(4)
|
||||
}
|
||||
quoteLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(rail.snp.trailing).offset(14)
|
||||
make.trailing.equalToSuperview().inset(18)
|
||||
make.verticalEdges.equalToSuperview().inset(16)
|
||||
}
|
||||
textCard.snp.makeConstraints { make in
|
||||
make.top.equalTo(quoteCard.snp.bottom).offset(18)
|
||||
make.horizontalEdges.equalToSuperview().inset(20)
|
||||
make.bottom.equalTo(view.keyboardLayoutGuide.snp.top).offset(-16)
|
||||
make.height.greaterThanOrEqualTo(210)
|
||||
}
|
||||
textView.snp.makeConstraints { make in
|
||||
make.top.horizontalEdges.equalToSuperview().inset(14)
|
||||
make.bottom.equalTo(countLabel.snp.top).offset(-8)
|
||||
}
|
||||
countLabel.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview().inset(16)
|
||||
make.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
updateCount()
|
||||
DispatchQueue.main.async { [weak self] in self?.textView.becomeFirstResponder() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// SDK 顶部阅读工具栏:业务动作通过闭包回传宿主。
|
||||
public final class RDPDFReaderKitTopToolView: UIView {
|
||||
public var onBack: (() -> Void)?
|
||||
public var onSearch: (() -> Void)?
|
||||
public var onToggleBookmark: (() -> Void)?
|
||||
|
||||
private let backButton = UIButton(type: .system)
|
||||
private let bookmarkButton = UIButton(type: .system)
|
||||
private let titleLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.textAlignment = .center
|
||||
label.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
label.numberOfLines = 1
|
||||
label.textColor = .darkText
|
||||
return label
|
||||
}()
|
||||
private let separatorLine = UIView()
|
||||
private var isBookmarked = false
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.topToolbar"
|
||||
backgroundColor = .white
|
||||
[backButton, bookmarkButton, titleLabel, separatorLine].forEach(addSubview)
|
||||
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
[backButton, bookmarkButton].forEach { $0.tintColor = .darkText }
|
||||
backButton.accessibilityIdentifier = "epub.reader.back"
|
||||
bookmarkButton.accessibilityIdentifier = "epub.reader.bookmark"
|
||||
titleLabel.accessibilityIdentifier = "epub.reader.title"
|
||||
separatorLine.backgroundColor = UIColor(white: 0, alpha: 0.12)
|
||||
backButton.addTarget(self, action: #selector(backAction), for: .touchUpInside)
|
||||
bookmarkButton.addTarget(self, action: #selector(bookmarkAction), for: .touchUpInside)
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(8)
|
||||
make.top.equalTo(safeAreaLayoutGuide).offset(4)
|
||||
make.bottom.equalToSuperview().inset(4)
|
||||
make.width.equalTo(44)
|
||||
}
|
||||
bookmarkButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(8)
|
||||
make.top.equalTo(safeAreaLayoutGuide).offset(4)
|
||||
make.bottom.equalToSuperview().inset(4)
|
||||
make.width.equalTo(44)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(backButton.snp.trailing).offset(8)
|
||||
make.trailing.equalTo(bookmarkButton.snp.leading).offset(-8)
|
||||
make.centerY.equalTo(backButton)
|
||||
}
|
||||
separatorLine.snp.makeConstraints { make in
|
||||
make.horizontalEdges.bottom.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public func setTitle(_ title: String?) { titleLabel.text = title }
|
||||
|
||||
public func setBookmarkSelected(_ isSelected: Bool) {
|
||||
isBookmarked = isSelected
|
||||
updateBookmarkButtonAppearance()
|
||||
}
|
||||
|
||||
public func apply(
|
||||
backgroundColor: UIColor,
|
||||
tintColor: UIColor,
|
||||
separatorColor: UIColor
|
||||
) {
|
||||
self.backgroundColor = backgroundColor
|
||||
[backButton, bookmarkButton].forEach { $0.tintColor = tintColor }
|
||||
titleLabel.textColor = tintColor
|
||||
separatorLine.backgroundColor = separatorColor
|
||||
}
|
||||
|
||||
private func updateBookmarkButtonAppearance() {
|
||||
bookmarkButton.setImage(UIImage(systemName: isBookmarked ? "bookmark.fill" : "bookmark"), for: .normal)
|
||||
bookmarkButton.accessibilityValue = isBookmarked ? "selected" : "unselected"
|
||||
}
|
||||
|
||||
@objc private func backAction() { onBack?() }
|
||||
@objc private func searchAction() { onSearch?() }
|
||||
@objc private func bookmarkAction() { onToggleBookmark?() }
|
||||
}
|
||||
|
||||
/// SDK 底部阅读工具栏:目录和设置由宿主回调承接。
|
||||
public final class RDPDFReaderKitBottomToolView: UIView {
|
||||
public var onShowTableOfContents: (() -> Void)?
|
||||
public var onShowAnnotations: (() -> Void)?
|
||||
public var onStartDrawing: (() -> Void)?
|
||||
public var onShowSettings: (() -> Void)?
|
||||
|
||||
private let stackView: UIStackView = {
|
||||
let view = UIStackView()
|
||||
view.axis = .horizontal
|
||||
view.distribution = .fillEqually
|
||||
view.spacing = 16
|
||||
return view
|
||||
}()
|
||||
private let catalogButton = UIButton(type: .system)
|
||||
private let annotationsButton = UIButton(type: .system)
|
||||
private let drawingButton = UIButton(type: .system)
|
||||
private let settingsButton = UIButton(type: .system)
|
||||
private let separatorLine = UIView()
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.bottomToolbar"
|
||||
backgroundColor = .white
|
||||
addSubview(stackView)
|
||||
addSubview(separatorLine)
|
||||
separatorLine.backgroundColor = UIColor(white: 0, alpha: 0.12)
|
||||
stackView.addArrangedSubview(catalogButton)
|
||||
stackView.addArrangedSubview(annotationsButton)
|
||||
stackView.addArrangedSubview(drawingButton)
|
||||
stackView.addArrangedSubview(settingsButton)
|
||||
stackView.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview().inset(16)
|
||||
make.top.equalToSuperview()
|
||||
make.bottom.equalTo(safeAreaLayoutGuide)
|
||||
}
|
||||
separatorLine.snp.makeConstraints { make in
|
||||
make.horizontalEdges.top.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
configure(catalogButton, image: "list.bullet")
|
||||
configure(annotationsButton, image: "note.text")
|
||||
configure(drawingButton, image: "pencil.tip")
|
||||
configure(settingsButton, image: "gearshape")
|
||||
catalogButton.accessibilityIdentifier = "epub.reader.toc"
|
||||
annotationsButton.accessibilityIdentifier = "pdf.reader.annotations"
|
||||
annotationsButton.accessibilityLabel = "笔记"
|
||||
drawingButton.accessibilityIdentifier = "pdf.reader.drawing"
|
||||
drawingButton.accessibilityLabel = "画笔"
|
||||
settingsButton.accessibilityIdentifier = "epub.reader.settings"
|
||||
catalogButton.addTarget(self, action: #selector(catalogAction), for: .touchUpInside)
|
||||
annotationsButton.addTarget(self, action: #selector(annotationsAction), for: .touchUpInside)
|
||||
drawingButton.addTarget(self, action: #selector(drawingAction), for: .touchUpInside)
|
||||
settingsButton.addTarget(self, action: #selector(settingsAction), for: .touchUpInside)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public func apply(
|
||||
backgroundColor: UIColor,
|
||||
tintColor: UIColor,
|
||||
separatorColor: UIColor
|
||||
) {
|
||||
self.backgroundColor = backgroundColor
|
||||
[catalogButton, annotationsButton, drawingButton, settingsButton].forEach { $0.tintColor = tintColor }
|
||||
separatorLine.backgroundColor = separatorColor
|
||||
}
|
||||
|
||||
private func configure(_ button: UIButton, image: String) {
|
||||
button.tintColor = .darkText
|
||||
button.setImage(UIImage(systemName: image), for: .normal)
|
||||
button.snp.makeConstraints { $0.height.greaterThanOrEqualTo(44) }
|
||||
}
|
||||
|
||||
@objc private func catalogAction() { onShowTableOfContents?() }
|
||||
@objc private func annotationsAction() { onShowAnnotations?() }
|
||||
@objc private func drawingAction() { onStartDrawing?() }
|
||||
@objc private func settingsAction() { onShowSettings?() }
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
@objc public protocol RDPDFReaderDataSource: NSObjectProtocol {
|
||||
func pageCountOfReaderView(readerView: RDPDFReaderView) -> Int
|
||||
func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView
|
||||
@objc optional func topToolView(readerView: RDPDFReaderView) -> UIView?
|
||||
@objc optional func bottomToolView(readerView: RDPDFReaderView) -> UIView?
|
||||
}
|
||||
|
||||
@objc public protocol RDPDFReaderDelegate: NSObjectProtocol {
|
||||
func pageNum(readerView: RDPDFReaderView, pageNum: Int)
|
||||
@objc optional func toolViewVisibilityChanged(readerView: RDPDFReaderView, isVisible: Bool)
|
||||
}
|
||||
|
||||
/// 让宿主页面向 SDK 报告点击和缩放状态,SDK 不依赖具体 PDF 页面类型。
|
||||
public protocol RDPDFReaderPageInteractable: AnyObject {
|
||||
var readerContentTapHandler: ((CGPoint) -> Void)? { get set }
|
||||
var readerZoomStateChangedHandler: ((Bool) -> Void)? { get set }
|
||||
/// 文本/区域选区激活时,阅读器暂停翻页和内容点击,避免长按拖选被识别为翻页。
|
||||
var readerSelectionStateChangedHandler: ((Bool) -> Void)? { get set }
|
||||
var readerIsZoomed: Bool { get }
|
||||
var readerHasActiveTextSelection: Bool { get }
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool)
|
||||
func readerClearTextSelection()
|
||||
func readerBeginExternalPinch(at point: CGPoint)
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint)
|
||||
func readerEndExternalPinch()
|
||||
func readerBeginExternalPan()
|
||||
func readerUpdateExternalPan(translation: CGPoint)
|
||||
func readerEndExternalPan()
|
||||
}
|
||||
|
||||
public extension RDPDFReaderPageInteractable {
|
||||
var readerSelectionStateChangedHandler: ((Bool) -> Void)? {
|
||||
get { nil }
|
||||
set {}
|
||||
}
|
||||
var readerHasActiveTextSelection: Bool { false }
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool) {}
|
||||
func readerClearTextSelection() {}
|
||||
func readerBeginExternalPinch(at point: CGPoint) {}
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {}
|
||||
func readerEndExternalPinch() {}
|
||||
func readerBeginExternalPan() {}
|
||||
func readerUpdateExternalPan(translation: CGPoint) {}
|
||||
func readerEndExternalPan() {}
|
||||
}
|
||||
|
||||
public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
public enum DisplayType { case pageCurl, horizontalScroll, verticalScroll, horizontalCoverScroll }
|
||||
enum CurlPanInteraction {
|
||||
case idle
|
||||
case pageTurn(startPage: Int)
|
||||
case contentPan
|
||||
case pinching
|
||||
}
|
||||
public weak var dataSource: RDPDFReaderDataSource?
|
||||
public weak var delegate: RDPDFReaderDelegate?
|
||||
public var currentDisplayType: DisplayType = .pageCurl
|
||||
/// 与 EPUB 阅读器一致:是否允许横屏在非竖滑模式下显示双页。
|
||||
public var landscapeDualPageEnabled: Bool = false {
|
||||
didSet {
|
||||
guard oldValue != landscapeDualPageEnabled else { return }
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
/// 手机横屏竖滑:每页 cell 高度是宽度适配后的纸张高度,纵向超出一屏的内容
|
||||
/// 由外层 collectionView 连续滚动承接,页面内部不再嵌套竖向滚动。
|
||||
/// 参数为页码与 cell 宽度;返回 nil(页面尚未加载)时回退为一屏高。
|
||||
public var verticalScrollWidthFitPageHeightProvider: ((Int, CGFloat) -> CGFloat?)?
|
||||
/// 由宿主在方向变化时设置。同为竖滑的横竖屏切换不会触发 displayType 变更,
|
||||
/// 因此这里主动重建 cell,让新的适配模式和 cell 高度立即生效。
|
||||
public var verticalScrollWidthFitEnabled = false {
|
||||
didSet {
|
||||
guard oldValue != verticalScrollWidthFitEnabled,
|
||||
currentDisplayType == .verticalScroll else { return }
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.reloadData()
|
||||
if currentPage >= 0 { transitionToPage(pageNum: currentPage, animated: false) }
|
||||
}
|
||||
}
|
||||
public internal(set) var currentPage = -1 { didSet { if oldValue != currentPage { synchronizePageState(); delegate?.pageNum(readerView: self, pageNum: currentPage) } } }
|
||||
public internal(set) var isToolViewVisible = false
|
||||
|
||||
private let layout = UICollectionViewFlowLayout()
|
||||
let spreadResolver = RDPDFReaderSpreadResolver()
|
||||
let pagingController = RDPDFReaderPagingController()
|
||||
lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
let curlHostView = UIView()
|
||||
/// 放大态不复用翻页中的页面视图:以当前 spread 重建一个整体画布,
|
||||
/// 从根源上隔离 UIPageViewController/UICollectionView 的翻页手势。
|
||||
let zoomOverlayView = RDPDFReaderZoomOverlayView()
|
||||
lazy var readerPinchGestureRecognizer: UIPinchGestureRecognizer = {
|
||||
let gesture = UIPinchGestureRecognizer(target: self, action: #selector(handleReaderPinch(_:)))
|
||||
gesture.delegate = self
|
||||
return gesture
|
||||
}()
|
||||
lazy var readerDoubleTapGestureRecognizer: UITapGestureRecognizer = {
|
||||
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleReaderDoubleTap(_:)))
|
||||
gesture.numberOfTapsRequired = 2
|
||||
gesture.delegate = self
|
||||
return gesture
|
||||
}()
|
||||
var nativeCurlPageController: UIPageViewController?
|
||||
lazy var curlPanGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handleCurlPan(_:)))
|
||||
gesture.minimumNumberOfTouches = 1
|
||||
gesture.maximumNumberOfTouches = 1
|
||||
gesture.delegate = self
|
||||
return gesture
|
||||
}()
|
||||
lazy var curlPinchGestureRecognizer: UIPinchGestureRecognizer = {
|
||||
let gesture = UIPinchGestureRecognizer(target: self, action: #selector(handleCurlPinch(_:)))
|
||||
gesture.cancelsTouchesInView = false
|
||||
gesture.delegate = self
|
||||
return gesture
|
||||
}()
|
||||
var curlContentView: UIView?
|
||||
var curlPanInteraction = CurlPanInteraction.idle
|
||||
var isPagingEnabled = true
|
||||
var isCurrentPageZoomed = false
|
||||
var isCurrentPageTextSelectionActive = false
|
||||
var topToolView: UIView?
|
||||
var bottomToolView: UIView?
|
||||
var topToolViewHeightConstraint: Constraint?
|
||||
var bottomToolViewHeightConstraint: Constraint?
|
||||
var isTransitioning: Bool {
|
||||
get { pagingController.isTransitioning }
|
||||
set { pagingController.isTransitioning = newValue }
|
||||
}
|
||||
var pendingPage: (Int, Bool)? {
|
||||
get { pagingController.pendingTransition.map { ($0.page, $0.animated) } }
|
||||
set { pagingController.pendingTransition = newValue.map { (page: $0.0, animated: $0.1) } }
|
||||
}
|
||||
private var appliedLandscapeSpread = false
|
||||
/// UICollectionViewFlowLayout 不会因为仅修改 itemSize 就立即丢弃旋转前的布局缓存。
|
||||
/// 记录上一次实际应用的尺寸,横竖屏变化时主动失效,避免横屏首帧仍排入两个竖屏 cell。
|
||||
private var appliedCollectionItemSize = CGSize.zero
|
||||
private var reusableTypes: [String: UIView.Type] = [:]
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setup()
|
||||
}
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
private func setup() {
|
||||
layout.minimumLineSpacing = 0
|
||||
layout.minimumInteritemSpacing = 0
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.accessibilityIdentifier = "epub.reader.paging"
|
||||
collectionView.dataSource = self
|
||||
collectionView.delegate = self
|
||||
collectionView.register(RDPDFReaderContentCell.self, forCellWithReuseIdentifier: "fallback")
|
||||
collectionView.frame = bounds
|
||||
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
addSubview(collectionView)
|
||||
|
||||
// 双页原生卷页控制器在中轴会露出宿主底色;保持透明,避免出现人为灰色中缝。
|
||||
curlHostView.backgroundColor = .clear
|
||||
curlHostView.clipsToBounds = true
|
||||
curlHostView.accessibilityIdentifier = "pdf.reader.pageCurlHost"
|
||||
curlHostView.frame = bounds
|
||||
curlHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
curlHostView.addGestureRecognizer(curlPanGestureRecognizer)
|
||||
curlHostView.addGestureRecognizer(curlPinchGestureRecognizer)
|
||||
addSubview(curlHostView)
|
||||
|
||||
zoomOverlayView.frame = bounds
|
||||
zoomOverlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
zoomOverlayView.isHidden = true
|
||||
zoomOverlayView.onZoomStateChanged = { [weak self] zoomed in
|
||||
guard let self else { return }
|
||||
self.isCurrentPageZoomed = zoomed
|
||||
self.updatePagingState()
|
||||
if !zoomed { self.dismissZoomOverlay() }
|
||||
}
|
||||
addSubview(zoomOverlayView)
|
||||
addGestureRecognizer(readerPinchGestureRecognizer)
|
||||
addGestureRecognizer(readerDoubleTapGestureRecognizer)
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let spread = usesLandscapeSpread
|
||||
let itemSize = spread
|
||||
? CGSize(width: bounds.width / 2, height: bounds.height)
|
||||
: bounds.size
|
||||
let itemSizeChanged = appliedCollectionItemSize != itemSize
|
||||
if itemSizeChanged {
|
||||
appliedCollectionItemSize = itemSize
|
||||
layout.itemSize = itemSize
|
||||
layout.invalidateLayout()
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.layoutIfNeeded()
|
||||
|
||||
// invalidateLayout 会保留旧 contentOffset;按新页面尺寸重新锚定当前页,
|
||||
// 否则旋转后会停在半页或仍看到相邻页。
|
||||
if currentDisplayType != .pageCurl, currentPage >= 0 {
|
||||
let displayedPage = usesLandscapeSpread
|
||||
? spreadResolver.pair(for: currentPage, totalPages: pageCount()).left
|
||||
: currentPage
|
||||
let offset = currentDisplayType == .verticalScroll
|
||||
// 宽度适配竖滑下各 cell 高度不同,不能按等高乘法换算,取真实布局位置。
|
||||
? CGPoint(x: 0, y: clampedVerticalOffset(anchorY(forItem: displayedPage)))
|
||||
: CGPoint(x: CGFloat(usesLandscapeSpread ? displayedPage / 2 : displayedPage) * bounds.width, y: 0)
|
||||
collectionView.setContentOffset(offset, animated: false)
|
||||
}
|
||||
}
|
||||
curlHostView.frame = bounds
|
||||
curlContentView?.frame = curlHostView.bounds
|
||||
updateToolViewHeightConstraintsIfNeeded()
|
||||
guard appliedLandscapeSpread != spread else { return }
|
||||
// 旋转会使 collection/UIPageViewController 重排其外壳。先归还实际页面,
|
||||
// 再切换单页/双页结构,避免 Overlay 持有已失效的父视图。
|
||||
if zoomOverlayView.isPresented { dismissZoomOverlay() }
|
||||
appliedLandscapeSpread = spread
|
||||
layout.invalidateLayout()
|
||||
guard currentPage >= 0 else { return }
|
||||
// 旋转后必须重新创建当前内容:仿真模式从单页切成 spread,反之亦然。
|
||||
curlContentView?.removeFromSuperview()
|
||||
curlContentView = nil
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.switchReaderDisplayType(self?.currentDisplayType ?? .pageCurl)
|
||||
}
|
||||
}
|
||||
|
||||
public func reloadData() {
|
||||
dismissZoomOverlay()
|
||||
topToolView = dataSource?.topToolView?(readerView: self)
|
||||
bottomToolView = dataSource?.bottomToolView?(readerView: self)
|
||||
curlContentView?.removeFromSuperview()
|
||||
curlContentView = nil
|
||||
curlPanInteraction = .idle
|
||||
currentPage = -1
|
||||
switchReaderDisplayType(currentDisplayType)
|
||||
}
|
||||
|
||||
public func switchReaderDisplayType(_ type: DisplayType) {
|
||||
dismissZoomOverlay()
|
||||
currentDisplayType = type
|
||||
// pagesPerScreen 同时依赖方向和翻页方式。旋转过程中先按仿真模式完成的
|
||||
// 横屏布局可能已经把 itemSize 缓存成半屏;切到手机强制竖滑后必须立即
|
||||
// 重新执行自身布局,不能等待下一次点击或系统布局事件。
|
||||
setNeedsLayout()
|
||||
layoutIfNeeded()
|
||||
curlPanInteraction = .idle
|
||||
(curlContentView as? RDPDFReaderPageInteractable)?.readerClearTextSelection()
|
||||
if currentPage < 0 { currentPage = 0 }
|
||||
let isCurl = type == .pageCurl
|
||||
collectionView.isHidden = isCurl
|
||||
curlHostView.isHidden = !isCurl
|
||||
if isCurl { rebuildNativeCurlController() }
|
||||
isCurrentPageZoomed = false
|
||||
isCurrentPageTextSelectionActive = false
|
||||
updatePagingState()
|
||||
(curlContentView as? RDPDFReaderPageInteractable)?.readerSetInternalGesturesEnabled(!isCurl)
|
||||
layout.scrollDirection = type == .verticalScroll ? .vertical : .horizontal
|
||||
collectionView.isPagingEnabled = type != .verticalScroll
|
||||
collectionView.reloadData()
|
||||
transitionToPage(pageNum: currentPage, animated: false)
|
||||
}
|
||||
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false) {
|
||||
guard isPagingEnabled, !isCurrentPageZoomed, !isCurrentPageTextSelectionActive, let page = clamped(pageNum) else { return }
|
||||
let displayedPage = usesLandscapeSpread ? spreadResolver.pair(for: page, totalPages: pageCount()).left : page
|
||||
if currentDisplayType == .pageCurl {
|
||||
transitionCurl(to: displayedPage, animated: animated)
|
||||
} else {
|
||||
collectionView.layoutIfNeeded()
|
||||
let offset = currentDisplayType == .verticalScroll
|
||||
? CGPoint(x: 0, y: clampedVerticalOffset(anchorY(forItem: displayedPage)))
|
||||
: CGPoint(x: CGFloat(usesLandscapeSpread ? displayedPage / 2 : displayedPage) * bounds.width, y: 0)
|
||||
collectionView.setContentOffset(offset, animated: animated)
|
||||
currentPage = displayedPage
|
||||
}
|
||||
}
|
||||
|
||||
/// 竖滑模式下页面的实际顶部位置。宽度适配时 cell 高度按页给出,
|
||||
/// 布局属性是唯一可靠来源;尚未布局时回退为等高换算。
|
||||
func anchorY(forItem item: Int) -> CGFloat {
|
||||
collectionView.layoutAttributesForItem(at: IndexPath(item: item, section: 0))?.frame.minY
|
||||
?? CGFloat(item) * bounds.height
|
||||
}
|
||||
|
||||
func clampedVerticalOffset(_ y: CGFloat) -> CGFloat {
|
||||
let maxY = max(0, collectionView.contentSize.height - collectionView.bounds.height)
|
||||
return min(max(0, y), maxY)
|
||||
}
|
||||
|
||||
var usesWidthFitVerticalScroll: Bool {
|
||||
currentDisplayType == .verticalScroll && verticalScrollWidthFitEnabled
|
||||
}
|
||||
|
||||
/// 宽度适配竖滑下页面异步加载完成后,cell 高度会从"一屏高"变为真实纸张高度,
|
||||
/// 宿主拿到页面数据时调用此方法刷新布局。
|
||||
public func invalidateWidthFitLayoutIfNeeded() {
|
||||
guard usesWidthFitVerticalScroll else { return }
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
}
|
||||
|
||||
public func setPagingEnabled(_ enabled: Bool) { isPagingEnabled = enabled; updatePagingState() }
|
||||
public func hideToolViewIfNeeded() { if isToolViewVisible { toggleToolbars() } }
|
||||
public func refreshCurrentPageIfNeeded() { pageContentView(pageNum: currentPage)?.setNeedsLayout() }
|
||||
|
||||
public func register(contentView: UIView.Type, contentViewWithReuseIdentifier identifier: String) {
|
||||
reusableTypes[identifier] = contentView
|
||||
collectionView.register(RDPDFReaderContentCell.self, forCellWithReuseIdentifier: identifier)
|
||||
}
|
||||
|
||||
public func dequeueReusableContentView(withReuseIdentifier identifier: String, for pageNum: Int) -> UIView {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifier, for: IndexPath(item: pageNum, section: 0)) as! RDPDFReaderContentCell
|
||||
if cell.hostedView == nil, let type = reusableTypes[identifier] { cell.hostedView = type.init() }
|
||||
return cell.hostedView ?? UIView()
|
||||
}
|
||||
|
||||
public func pageContentView(pageNum: Int) -> UIView? {
|
||||
if zoomOverlayView.isPresented, let page = zoomOverlayView.page(at: pageNum) { return page }
|
||||
if currentDisplayType == .pageCurl {
|
||||
if let child = nativeCurlPageController?.viewControllers?.first(where: { ($0 as? RDPDFReaderPageChildViewController)?.page == pageNum }) as? RDPDFReaderPageChildViewController { return child.contentView }
|
||||
if let child = nativeCurlPageController?.viewControllers?.last(where: { ($0 as? RDPDFReaderPageChildViewController)?.page == pageNum }) as? RDPDFReaderPageChildViewController { return child.contentView }
|
||||
if let spread = curlContentView as? RDPDFReaderPageSpreadView { return spread.page(at: pageNum) }
|
||||
return pageNum == currentPage ? curlContentView : nil
|
||||
}
|
||||
return (collectionView.cellForItem(at: IndexPath(item: pageNum, section: 0)) as? RDPDFReaderContentCell)?.hostedView
|
||||
}
|
||||
|
||||
/// 当前真正显示的内容页。双页卷页模式直接读取两个 child,避免用页码推算右页。
|
||||
func visiblePageContentViews() -> [UIView] {
|
||||
if zoomOverlayView.isPresented {
|
||||
return [currentPage, currentPage + 1].compactMap { zoomOverlayView.page(at: $0) }
|
||||
}
|
||||
if currentDisplayType == .pageCurl {
|
||||
return nativeCurlPageController?.viewControllers?
|
||||
.compactMap { ($0 as? RDPDFReaderPageChildViewController)?.contentView } ?? []
|
||||
}
|
||||
return collectionView.visibleCells.compactMap { ($0 as? RDPDFReaderContentCell)?.hostedView }
|
||||
}
|
||||
|
||||
var isLandscape: Bool { bounds.width > bounds.height }
|
||||
|
||||
/// 与 `RDEpubReaderView.pagesPerScreen` 的规则保持一致。
|
||||
var pagesPerScreen: Int {
|
||||
guard landscapeDualPageEnabled, isLandscape, currentDisplayType != .verticalScroll else { return 1 }
|
||||
return 2
|
||||
}
|
||||
|
||||
var usesLandscapeSpread: Bool { pagesPerScreen == 2 }
|
||||
|
||||
func rebuildNativeCurlController() {
|
||||
nativeCurlPageController?.view.removeFromSuperview()
|
||||
let options: [UIPageViewController.OptionsKey: Any]? = usesLandscapeSpread
|
||||
? [.spineLocation: NSNumber(value: UIPageViewController.SpineLocation.mid.rawValue)]
|
||||
: nil
|
||||
let controller = UIPageViewController(transitionStyle: .pageCurl, navigationOrientation: .horizontal, options: options)
|
||||
controller.dataSource = self
|
||||
controller.delegate = self
|
||||
controller.isDoubleSided = usesLandscapeSpread
|
||||
controller.view.frame = curlHostView.bounds
|
||||
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
controller.view.backgroundColor = .clear
|
||||
curlHostView.addSubview(controller.view)
|
||||
nativeCurlPageController = controller
|
||||
curlPanGestureRecognizer.isEnabled = false
|
||||
curlPinchGestureRecognizer.isEnabled = false
|
||||
}
|
||||
|
||||
func updateNativeCurlBookFrame() {
|
||||
guard currentDisplayType == .pageCurl,
|
||||
let controller = nativeCurlPageController,
|
||||
let firstChild = controller.viewControllers?.first as? RDPDFReaderPageChildViewController,
|
||||
let firstPage = firstChild.contentView as? RDPDFReaderPageView else { return }
|
||||
|
||||
// 先以宿主尺寸完成一次适配,再用实际纸张尺寸收缩卷页控制器。
|
||||
controller.view.layoutIfNeeded()
|
||||
let pageFrame = firstPage.fittedPageFrame(in: controller.view)
|
||||
guard pageFrame.width > 0, pageFrame.height > 0 else { return }
|
||||
let width = min(curlHostView.bounds.width, pageFrame.width * CGFloat(pagesPerScreen))
|
||||
let height = min(curlHostView.bounds.height, pageFrame.height)
|
||||
controller.view.frame = CGRect(
|
||||
x: (curlHostView.bounds.width - width) / 2,
|
||||
y: (curlHostView.bounds.height - height) / 2,
|
||||
width: width,
|
||||
height: height
|
||||
).integral
|
||||
controller.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
func pageCount() -> Int { dataSource?.pageCountOfReaderView(readerView: self) ?? 0 }
|
||||
func clamped(_ page: Int) -> Int? { let count = pageCount(); return count > 0 ? max(0, min(page, count - 1)) : nil }
|
||||
func finishCurlTransition() { isTransitioning = false; if let pendingPage { self.pendingPage = nil; transitionToPage(pageNum: pendingPage.0, animated: pendingPage.1) } }
|
||||
|
||||
public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer === readerPinchGestureRecognizer {
|
||||
return !zoomOverlayView.isPresented && currentPage >= 0 && !isTransitioning
|
||||
}
|
||||
if gestureRecognizer === readerDoubleTapGestureRecognizer {
|
||||
return !zoomOverlayView.isPresented && currentPage >= 0 && !isTransitioning
|
||||
}
|
||||
if gestureRecognizer === curlPanGestureRecognizer {
|
||||
guard currentDisplayType == .pageCurl,
|
||||
isPagingEnabled,
|
||||
!isCurrentPageTextSelectionActive,
|
||||
!isTransitioning else {
|
||||
return false
|
||||
}
|
||||
switch curlPanInteraction {
|
||||
case .pinching:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
if gestureRecognizer === curlPinchGestureRecognizer {
|
||||
guard currentDisplayType == .pageCurl, curlContentView != nil, !isTransitioning else { return false }
|
||||
if isCurrentPageTextSelectionActive {
|
||||
(curlContentView as? RDPDFReaderPageInteractable)?.readerClearTextSelection()
|
||||
}
|
||||
return true
|
||||
}
|
||||
return super.gestureRecognizerShouldBegin(gestureRecognizer)
|
||||
}
|
||||
|
||||
public func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
(gestureRecognizer === curlPanGestureRecognizer && otherGestureRecognizer === curlPinchGestureRecognizer)
|
||||
|| (gestureRecognizer === curlPinchGestureRecognizer && otherGestureRecognizer === curlPanGestureRecognizer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 独立放大层
|
||||
|
||||
extension RDPDFReaderView {
|
||||
@objc func handleReaderPinch(_ gesture: UIPinchGestureRecognizer) {
|
||||
let point = gesture.location(in: self)
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
presentZoomOverlay(focusPoint: point)
|
||||
zoomOverlayView.beginPinch(at: point)
|
||||
case .changed:
|
||||
zoomOverlayView.updatePinch(scale: gesture.scale, at: point)
|
||||
case .ended, .cancelled, .failed:
|
||||
zoomOverlayView.endPinch()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleReaderDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
let point = gesture.location(in: self)
|
||||
presentZoomOverlay(focusPoint: point)
|
||||
zoomOverlayView.toggleZoom(around: point)
|
||||
}
|
||||
|
||||
func presentZoomOverlay(focusPoint: CGPoint) {
|
||||
guard !zoomOverlayView.isPresented, let page = clamped(currentPage) else { return }
|
||||
let pair = spreadResolver.pair(for: page, totalPages: pageCount())
|
||||
let leftIndex = usesLandscapeSpread ? pair.left : page
|
||||
guard let leftPage = pageContentView(pageNum: leftIndex) else { return }
|
||||
let rightPage: UIView?
|
||||
if usesLandscapeSpread, let right = pair.right {
|
||||
guard let visibleRightPage = pageContentView(pageNum: right) else { return }
|
||||
rightPage = visibleRightPage
|
||||
} else {
|
||||
rightPage = nil
|
||||
}
|
||||
let bookFrame = currentBookFrame(using: leftPage)
|
||||
zoomOverlayView.present(
|
||||
leftPage: leftPage,
|
||||
rightPage: rightPage,
|
||||
bookSize: bookFrame.size,
|
||||
backgroundColor: backgroundColor ?? .white
|
||||
)
|
||||
isCurrentPageZoomed = true
|
||||
updatePagingState()
|
||||
}
|
||||
|
||||
func dismissZoomOverlay() {
|
||||
guard zoomOverlayView.isPresented else { return }
|
||||
zoomOverlayView.dismiss()
|
||||
isCurrentPageZoomed = false
|
||||
updatePagingState()
|
||||
}
|
||||
|
||||
private func currentBookFrame(using fallbackPage: UIView) -> CGRect {
|
||||
if let visible = pageContentView(pageNum: currentPage) as? RDPDFReaderPageView {
|
||||
let frame = visible.fittedPageFrame(in: self)
|
||||
if frame.width > 0, frame.height > 0 {
|
||||
return CGRect(x: 0, y: 0, width: frame.width * CGFloat(pagesPerScreen), height: frame.height)
|
||||
}
|
||||
}
|
||||
if let page = fallbackPage as? RDPDFReaderPageView {
|
||||
page.frame = bounds
|
||||
page.layoutIfNeeded()
|
||||
let frame = page.fittedPageFrame(in: page)
|
||||
if frame.width > 0, frame.height > 0 {
|
||||
return CGRect(x: 0, y: 0, width: frame.width * CGFloat(pagesPerScreen), height: frame.height)
|
||||
}
|
||||
}
|
||||
return CGRect(origin: .zero, size: bounds.size)
|
||||
}
|
||||
}
|
||||
|
||||
/// 放大阅读态的唯一手势容器。它承载当前单页或横屏双页 spread,底层翻页视图不会
|
||||
/// 参与缩放或拖动。页面仍由同一 dataSource 创建,因而图片、OCR 和标注保持一致。
|
||||
final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureRecognizerDelegate {
|
||||
private struct PageLease {
|
||||
let page: UIView
|
||||
weak var parent: UIView?
|
||||
let frame: CGRect
|
||||
let autoresizingMask: UIView.AutoresizingMask
|
||||
}
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let canvasView = UIView()
|
||||
private let doubleTapGesture = UITapGestureRecognizer()
|
||||
private var pinchStartScale: CGFloat = 1
|
||||
private var reportedZoomed = false
|
||||
/// 双击缩小期间页面仍租借给 Overlay;必须等 UIScrollView 的缩放动画结束后再归还。
|
||||
private var isAnimatingZoomOut = false
|
||||
/// 捏合缩小时允许暂时小于 1 倍;不能在手势中途归还页面。
|
||||
private var isInteractivePinching = false
|
||||
private var pageLeases: [PageLease] = []
|
||||
|
||||
var onZoomStateChanged: ((Bool) -> Void)?
|
||||
private(set) var isPresented = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
scrollView.delegate = self
|
||||
// 小于 1 倍用于承接“捏合缩小后松手”的回弹动画,不会作为最终阅读比例保留。
|
||||
scrollView.minimumZoomScale = 0.65
|
||||
scrollView.maximumZoomScale = 3
|
||||
scrollView.bouncesZoom = true
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
// Overlay 显示后的后续捏合由 UIScrollView 自己接收,也必须遵循缩小回弹流程。
|
||||
scrollView.pinchGestureRecognizer?.addTarget(self, action: #selector(handleOverlayPinch(_:)))
|
||||
addSubview(scrollView)
|
||||
scrollView.addSubview(canvasView)
|
||||
doubleTapGesture.addTarget(self, action: #selector(handleDoubleTap(_:)))
|
||||
doubleTapGesture.numberOfTapsRequired = 2
|
||||
doubleTapGesture.delegate = self
|
||||
addGestureRecognizer(doubleTapGesture)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
scrollView.frame = bounds
|
||||
updateInsets()
|
||||
}
|
||||
|
||||
func present(leftPage: UIView, rightPage: UIView?, bookSize: CGSize, backgroundColor: UIColor) {
|
||||
self.backgroundColor = backgroundColor
|
||||
restoreLeasedPages()
|
||||
let safeSize = CGSize(width: max(1, bookSize.width), height: max(1, bookSize.height))
|
||||
canvasView.frame = CGRect(origin: .zero, size: safeSize)
|
||||
scrollView.contentSize = safeSize
|
||||
lease(leftPage, frame: CGRect(x: 0, y: 0, width: rightPage == nil ? safeSize.width : safeSize.width / 2, height: safeSize.height))
|
||||
if let rightPage {
|
||||
lease(rightPage, frame: CGRect(x: safeSize.width / 2, y: 0, width: safeSize.width / 2, height: safeSize.height))
|
||||
}
|
||||
[leftPage, rightPage].compactMap { $0 as? RDPDFReaderPageInteractable }.forEach {
|
||||
$0.readerSetInternalGesturesEnabled(false)
|
||||
}
|
||||
scrollView.setZoomScale(1, animated: false)
|
||||
scrollView.contentOffset = .zero
|
||||
// 容器在 present 后已进入放大态;即便用户第一步就是缩小,也要保证结束时能发出 false。
|
||||
reportedZoomed = true
|
||||
isPresented = true
|
||||
isHidden = false
|
||||
updateInsets()
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
isAnimatingZoomOut = false
|
||||
isPresented = false
|
||||
isHidden = true
|
||||
scrollView.setZoomScale(1, animated: false)
|
||||
restoreLeasedPages()
|
||||
reportedZoomed = false
|
||||
}
|
||||
|
||||
func page(at index: Int) -> UIView? {
|
||||
pageLeases.map(\.page).first(where: { $0.tag == index })
|
||||
}
|
||||
|
||||
func beginPinch(at point: CGPoint) {
|
||||
isInteractivePinching = true
|
||||
pinchStartScale = scrollView.zoomScale
|
||||
}
|
||||
|
||||
func updatePinch(scale: CGFloat, at point: CGPoint) {
|
||||
guard isPresented else { return }
|
||||
let targetScale = min(scrollView.maximumZoomScale, max(scrollView.minimumZoomScale, pinchStartScale * scale))
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
let size = CGSize(width: bounds.width / targetScale, height: bounds.height / targetScale)
|
||||
let rect = CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height)
|
||||
scrollView.zoom(to: rect, animated: false)
|
||||
}
|
||||
|
||||
func endPinch() {
|
||||
isInteractivePinching = false
|
||||
guard scrollView.zoomScale <= 1.01 else {
|
||||
reportZoomState()
|
||||
return
|
||||
}
|
||||
// 手指可先缩到 1 倍以下;松手后回弹至阅读器的标准比例,动画完成后再还原页面层级。
|
||||
if scrollView.zoomScale < 0.999 {
|
||||
isAnimatingZoomOut = true
|
||||
scrollView.setZoomScale(1, animated: true)
|
||||
} else {
|
||||
// 恰好停在 1 倍时也延后一帧,避免在 UIScrollView 手势回调中换父视图。
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.isPresented, !self.isInteractivePinching else { return }
|
||||
self.reportZoomState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleZoom(around point: CGPoint) {
|
||||
guard isPresented else { return }
|
||||
if scrollView.zoomScale > 1.01 {
|
||||
// 不能在 scrollViewDidZoom 的第一帧就发出 false:那会把页面从 Overlay
|
||||
// 移回翻页容器,导致缩小动画中途跳变。
|
||||
isAnimatingZoomOut = true
|
||||
scrollView.setZoomScale(1, animated: true)
|
||||
} else {
|
||||
let scale: CGFloat = 2
|
||||
let size = CGSize(width: bounds.width / scale, height: bounds.height / scale)
|
||||
scrollView.zoom(to: CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? { canvasView }
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
updateInsets()
|
||||
// 放大状态已经上报过;捏合/缩小动画完成前保持 Overlay 持有页面。
|
||||
if !isAnimatingZoomOut, !(isInteractivePinching && scrollView.zoomScale <= 1.01) { reportZoomState() }
|
||||
}
|
||||
|
||||
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
// 原始手势结束与随后触发的回弹动画都会来到这里;前者仍小于 1 倍,不能提前归还页面。
|
||||
if isAnimatingZoomOut, scale < 0.999 { return }
|
||||
isAnimatingZoomOut = false
|
||||
// `reportZoomState()` 在此刻才触发 false,容器随后安全地恢复阅读/翻页视图。
|
||||
reportZoomState()
|
||||
}
|
||||
|
||||
private func updateInsets() {
|
||||
let size = canvasView.frame.size
|
||||
scrollView.contentInset = UIEdgeInsets(
|
||||
top: max(0, (bounds.height - size.height * scrollView.zoomScale) / 2),
|
||||
left: max(0, (bounds.width - size.width * scrollView.zoomScale) / 2),
|
||||
bottom: max(0, (bounds.height - size.height * scrollView.zoomScale) / 2),
|
||||
right: max(0, (bounds.width - size.width * scrollView.zoomScale) / 2)
|
||||
)
|
||||
}
|
||||
|
||||
private func reportZoomState() {
|
||||
let zoomed = scrollView.zoomScale > 1.01
|
||||
guard zoomed != reportedZoomed else { return }
|
||||
reportedZoomed = zoomed
|
||||
onZoomStateChanged?(zoomed)
|
||||
}
|
||||
|
||||
private func lease(_ page: UIView, frame: CGRect) {
|
||||
guard let parent = page.superview else { return }
|
||||
pageLeases.append(.init(page: page, parent: parent, frame: page.frame, autoresizingMask: page.autoresizingMask))
|
||||
page.removeFromSuperview()
|
||||
page.autoresizingMask = []
|
||||
canvasView.addSubview(page)
|
||||
page.frame = frame
|
||||
}
|
||||
|
||||
private func restoreLeasedPages() {
|
||||
guard !pageLeases.isEmpty else { return }
|
||||
for lease in pageLeases {
|
||||
lease.page.removeFromSuperview()
|
||||
guard let parent = lease.parent else { continue }
|
||||
parent.addSubview(lease.page)
|
||||
lease.page.frame = lease.frame
|
||||
lease.page.autoresizingMask = lease.autoresizingMask
|
||||
}
|
||||
pageLeases.removeAll()
|
||||
}
|
||||
|
||||
@objc private func handleOverlayPinch(_ gesture: UIPinchGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
isInteractivePinching = true
|
||||
case .ended, .cancelled, .failed:
|
||||
endPinch()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { toggleZoom(around: gesture.location(in: self)) }
|
||||
}
|
||||
|
||||
/// 横屏仿真翻页使用的双页容器。它仅组合两个宿主页面,不解释 PDF 内容。
|
||||
final class RDPDFReaderPageSpreadView: UIView, RDPDFReaderPageInteractable {
|
||||
private let leftPage: UIView
|
||||
private let rightPage: UIView?
|
||||
|
||||
init(leftPage: UIView, rightPage: UIView?) {
|
||||
self.leftPage = leftPage
|
||||
self.rightPage = rightPage
|
||||
super.init(frame: .zero)
|
||||
addSubview(leftPage)
|
||||
if let rightPage { addSubview(rightPage) }
|
||||
(leftPage as? RDPDFReaderPageView)?.setSpreadContentAlignment(rightPage == nil ? .centered : .trailing)
|
||||
(rightPage as? RDPDFReaderPageView)?.setSpreadContentAlignment(.leading)
|
||||
wireInteractions()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let leftWidth = rightPage == nil ? bounds.width : bounds.width / 2
|
||||
leftPage.frame = CGRect(x: 0, y: 0, width: leftWidth, height: bounds.height)
|
||||
rightPage?.frame = CGRect(x: leftWidth, y: 0, width: bounds.width - leftWidth, height: bounds.height)
|
||||
}
|
||||
|
||||
func page(at index: Int) -> UIView? {
|
||||
if leftPage.tag == index { return leftPage }
|
||||
if rightPage?.tag == index { return rightPage }
|
||||
return nil
|
||||
}
|
||||
|
||||
var readerContentTapHandler: ((CGPoint) -> Void)?
|
||||
var readerZoomStateChangedHandler: ((Bool) -> Void)?
|
||||
var readerSelectionStateChangedHandler: ((Bool) -> Void)?
|
||||
var readerIsZoomed: Bool { pages.contains { $0.readerIsZoomed } }
|
||||
var readerHasActiveTextSelection: Bool { pages.contains { $0.readerHasActiveTextSelection } }
|
||||
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool) { pages.forEach { $0.readerSetInternalGesturesEnabled(enabled) } }
|
||||
func readerClearTextSelection() { pages.forEach { $0.readerClearTextSelection() } }
|
||||
func readerBeginExternalPinch(at point: CGPoint) { activePage(at: point)?.readerBeginExternalPinch(at: point) }
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) { activePage(at: point)?.readerUpdateExternalPinch(scale: scale, at: point) }
|
||||
func readerEndExternalPinch() { pages.forEach { $0.readerEndExternalPinch() } }
|
||||
func readerBeginExternalPan() { pages.first?.readerBeginExternalPan() }
|
||||
func readerUpdateExternalPan(translation: CGPoint) { pages.first?.readerUpdateExternalPan(translation: translation) }
|
||||
func readerEndExternalPan() { pages.forEach { $0.readerEndExternalPan() } }
|
||||
|
||||
private var pages: [RDPDFReaderPageInteractable] { [leftPage, rightPage].compactMap { $0 as? RDPDFReaderPageInteractable } }
|
||||
private func activePage(at point: CGPoint) -> RDPDFReaderPageInteractable? {
|
||||
guard let rightPage, point.x >= bounds.midX else { return leftPage as? RDPDFReaderPageInteractable }
|
||||
return rightPage as? RDPDFReaderPageInteractable
|
||||
}
|
||||
|
||||
private func wireInteractions() {
|
||||
[leftPage, rightPage].compactMap { $0 }.forEach { page in
|
||||
guard let interactable = page as? RDPDFReaderPageInteractable else { return }
|
||||
interactable.readerContentTapHandler = { [weak self, weak page] point in
|
||||
guard let self, let page else { return }
|
||||
self.readerContentTapHandler?(page.convert(point, to: self))
|
||||
}
|
||||
interactable.readerZoomStateChangedHandler = { [weak self] _ in self?.readerZoomStateChangedHandler?(self?.readerIsZoomed ?? false) }
|
||||
interactable.readerSelectionStateChangedHandler = { [weak self] _ in self?.readerSelectionStateChangedHandler?(self?.readerHasActiveTextSelection ?? false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +253,20 @@ public protocol RDPDFReaderPersistence: AnyObject {
|
||||
func setBookmark(_ isBookmarked: Bool, pageIndex: Int, for bookIdentifier: String)
|
||||
}
|
||||
|
||||
/// 可选的标注存储能力。成品阅读器仅在提供此协议时启用标注列表、创建与编辑功能;
|
||||
/// 宿主可使用 SDK 的文件库,也可以接入自己的数据库实现。
|
||||
public protocol RDPDFReaderAnnotationPersisting: AnyObject {
|
||||
func loadAnnotations() throws -> [RDPDFReaderAnnotation]
|
||||
@discardableResult func addAnnotation(_ annotation: RDPDFReaderAnnotation) throws -> RDPDFReaderAnnotation
|
||||
@discardableResult func updateAnnotation(_ annotation: RDPDFReaderAnnotation) throws -> RDPDFReaderAnnotation?
|
||||
@discardableResult func deleteAnnotation(id: String) throws -> Bool
|
||||
}
|
||||
|
||||
/// 目录是可选的页面提供扩展;未实现时成品阅读器自动按页生成目录。
|
||||
public protocol RDPDFReaderOutlineProviding: AnyObject {
|
||||
func readerOutlineItems() -> [RDPDFReaderOutlineItem]
|
||||
}
|
||||
|
||||
/// 媒体、购买、外链等业务行为由宿主处理,SDK 不引用路由或播放器实现。
|
||||
public enum RDPDFReaderHostAction: Equatable {
|
||||
case back
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 无业务依赖的手势验证页面。用于确认问题是否来自阅读内核,而不是下载、渲染或主工程容器。
|
||||
public final class RDPDFReaderDebugViewController: UIViewController, RDPDFReaderKitViewDataSource {
|
||||
private let readerView = RDPDFReaderKitView()
|
||||
private let modeControl = UISegmentedControl(items: ["阅读", "画笔模式"])
|
||||
private var pages: [UIImage] = []
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .systemBackground
|
||||
title = "PDF 手势验证"
|
||||
|
||||
modeControl.selectedSegmentIndex = 0
|
||||
modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged)
|
||||
view.addSubview(modeControl)
|
||||
view.addSubview(readerView)
|
||||
modeControl.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
readerView.snp.makeConstraints { make in
|
||||
make.top.equalTo(modeControl.snp.bottom).offset(12)
|
||||
make.horizontalEdges.bottom.equalToSuperview()
|
||||
}
|
||||
|
||||
pages = (1...3).map(makeDebugPage)
|
||||
readerView.dataSource = self
|
||||
readerView.reloadData()
|
||||
}
|
||||
|
||||
@objc private func modeChanged() {
|
||||
readerView.isDrawingMode = modeControl.selectedSegmentIndex == 1
|
||||
}
|
||||
|
||||
public func numberOfPages(in readerView: RDPDFReaderKitView) -> Int { pages.count }
|
||||
|
||||
public func readerView(_ readerView: RDPDFReaderKitView, imageForPageAt index: Int) -> UIImage? {
|
||||
pages.indices.contains(index) ? pages[index] : nil
|
||||
}
|
||||
|
||||
private func makeDebugPage(_ index: Int) -> UIImage {
|
||||
let size = CGSize(width: 900, height: 1400)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.image { context in
|
||||
UIColor.white.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
let title = "PDF 手势验证页 \(index)"
|
||||
let text = "双指捏合:连续缩放\\n双击:放大 / 还原\\n放大后单指:拖动内容\\n画笔模式:单指不翻页,双指仍可缩放 / 拖动"
|
||||
title.draw(at: CGPoint(x: 70, y: 100), withAttributes: [.font: UIFont.boldSystemFont(ofSize: 52), .foregroundColor: UIColor.black])
|
||||
text.draw(in: CGRect(x: 70, y: 210, width: 760, height: 260), withAttributes: [.font: UIFont.systemFont(ofSize: 36), .foregroundColor: UIColor.darkGray])
|
||||
UIColor.systemBlue.withAlphaComponent(0.25).setFill()
|
||||
context.fill(CGRect(x: 70, y: 540, width: 760, height: 600))
|
||||
UIColor.systemBlue.setStroke()
|
||||
context.cgContext.setLineWidth(12)
|
||||
context.cgContext.strokeEllipse(in: CGRect(x: 180, y: 650, width: 540, height: 360))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import UIKit
|
||||
|
||||
public struct RDPDFReaderDrawingPath: Codable, Equatable {
|
||||
public let id: UUID
|
||||
public let page: Int
|
||||
public let color: String
|
||||
public let lineWidth: CGFloat
|
||||
public let tool: RDPDFReaderDrawingTool
|
||||
/// 笔迹所属图层。旧版文件没有该字段时会归入默认图层。
|
||||
public let layerID: UUID?
|
||||
public var points: [CGPoint]
|
||||
public let createdAt: Date
|
||||
|
||||
public init(id: UUID = UUID(), page: Int, color: String, lineWidth: CGFloat, tool: RDPDFReaderDrawingTool, layerID: UUID? = nil, points: [CGPoint], createdAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.page = page
|
||||
self.color = color
|
||||
self.lineWidth = lineWidth
|
||||
self.tool = tool
|
||||
self.layerID = layerID
|
||||
self.points = points
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case id, page, color, lineWidth, tool, layerID, points, createdAt }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
page = try container.decode(Int.self, forKey: .page)
|
||||
color = try container.decode(String.self, forKey: .color)
|
||||
lineWidth = try container.decode(CGFloat.self, forKey: .lineWidth)
|
||||
tool = try container.decode(RDPDFReaderDrawingTool.self, forKey: .tool)
|
||||
layerID = try container.decodeIfPresent(UUID.self, forKey: .layerID)
|
||||
createdAt = Date(timeIntervalSince1970: try container.decode(TimeInterval.self, forKey: .createdAt))
|
||||
let rawPoints = try container.decode([[CGFloat]].self, forKey: .points)
|
||||
points = rawPoints.compactMap { values in values.count >= 2 ? CGPoint(x: values[0], y: values[1]) : nil }
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(page, forKey: .page)
|
||||
try container.encode(color, forKey: .color)
|
||||
try container.encode(lineWidth, forKey: .lineWidth)
|
||||
try container.encode(tool, forKey: .tool)
|
||||
try container.encodeIfPresent(layerID, forKey: .layerID)
|
||||
try container.encode(points.map { [$0.x, $0.y] }, forKey: .points)
|
||||
try container.encode(createdAt.timeIntervalSince1970, forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 PDF 页内可独立显示、编辑和保存的画笔图层。
|
||||
public struct RDPDFReaderDrawingLayer: Codable, Equatable, Identifiable {
|
||||
public let id: UUID
|
||||
public var name: String
|
||||
public var isVisible: Bool
|
||||
|
||||
public init(id: UUID = UUID(), name: String, isVisible: Bool = true) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.isVisible = isVisible
|
||||
}
|
||||
}
|
||||
|
||||
/// 可由宿主保存的每页笔迹文档。SDK 不关心文件路径或数据库。
|
||||
public struct RDPDFReaderDrawingDocument: Codable {
|
||||
public let pageNo: Int
|
||||
public let paths: [RDPDFReaderDrawingPath]
|
||||
public let layers: [RDPDFReaderDrawingLayer]
|
||||
|
||||
public init(pageNo: Int, paths: [RDPDFReaderDrawingPath], layers: [RDPDFReaderDrawingLayer] = []) {
|
||||
self.pageNo = pageNo
|
||||
self.paths = paths
|
||||
self.layers = layers
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case pageNo, paths, layers }
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
pageNo = try container.decode(Int.self, forKey: .pageNo)
|
||||
paths = try container.decode([RDPDFReaderDrawingPath].self, forKey: .paths)
|
||||
layers = try container.decodeIfPresent([RDPDFReaderDrawingLayer].self, forKey: .layers) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDPDFReaderDrawingStrokePhase: Equatable {
|
||||
case began
|
||||
case moved
|
||||
case ended
|
||||
case cancelled
|
||||
}
|
||||
|
||||
/// PDF 阅读 SDK 的笔迹画布:负责触摸采样、渲染、橡皮擦与撤销/重做。
|
||||
/// 橡皮本身也是一条可持久化笔迹,采用透明清除混合,因而只擦掉经过的局部而非整条线。
|
||||
public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
public var tool: RDPDFReaderDrawingTool = .pen
|
||||
public var strokeColor: UIColor = .black
|
||||
public var lineWidth: CGFloat = 2
|
||||
public var currentPage = 0
|
||||
public var pathsChangedHandler: (([RDPDFReaderDrawingPath]) -> Void)?
|
||||
/// 将本页持续中的笔势交给展开页路由器。路由器可在书脊处把后续触点转发给相邻页。
|
||||
public var strokeEventHandler: ((CGPoint, RDPDFReaderDrawingStrokePhase) -> Void)?
|
||||
|
||||
private var paths: [RDPDFReaderDrawingPath] = []
|
||||
private var layers: [RDPDFReaderDrawingLayer] = []
|
||||
private var activeLayerID: UUID?
|
||||
private var currentPath: RDPDFReaderDrawingPath?
|
||||
private var activeDrawingTouch: UITouch?
|
||||
private var undoStack: [DrawingAction] = []
|
||||
private var redoStack: [DrawingAction] = []
|
||||
private let maxUndoCount = 50
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
contentScaleFactor = UIScreen.main.scale
|
||||
isMultipleTouchEnabled = true
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public func load(document: RDPDFReaderDrawingDocument) {
|
||||
paths = document.paths
|
||||
layers = document.layers
|
||||
if layers.isEmpty {
|
||||
let defaultLayer = RDPDFReaderDrawingLayer(name: "图层 1")
|
||||
layers = [defaultLayer]
|
||||
paths = paths.map { path in
|
||||
RDPDFReaderDrawingPath(id: path.id, page: path.page, color: path.color, lineWidth: path.lineWidth, tool: path.tool, layerID: defaultLayer.id, points: path.points, createdAt: path.createdAt)
|
||||
}
|
||||
}
|
||||
activeLayerID = layers.first(where: \.isVisible)?.id ?? layers.first?.id
|
||||
currentPath = nil
|
||||
undoStack.removeAll()
|
||||
redoStack.removeAll()
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
/// 兼容旧调用方,未分层笔迹自动归入“图层 1”。
|
||||
public func loadPaths(_ paths: [RDPDFReaderDrawingPath]) { load(document: .init(pageNo: currentPage, paths: paths)) }
|
||||
|
||||
public func currentPaths() -> [RDPDFReaderDrawingPath] { paths }
|
||||
public func drawingDocument() -> RDPDFReaderDrawingDocument { .init(pageNo: currentPage, paths: paths, layers: layers) }
|
||||
public func drawingLayers() -> [RDPDFReaderDrawingLayer] { layers }
|
||||
public func selectedDrawingLayerID() -> UUID? { activeLayerID }
|
||||
|
||||
@discardableResult public func addLayer() -> RDPDFReaderDrawingLayer {
|
||||
let layer = RDPDFReaderDrawingLayer(name: "图层 \(layers.count + 1)")
|
||||
layers.insert(layer, at: 0)
|
||||
activeLayerID = layer.id
|
||||
didChangePaths()
|
||||
return layer
|
||||
}
|
||||
|
||||
public func selectLayer(id: UUID) {
|
||||
guard layers.contains(where: { $0.id == id }) else { return }
|
||||
activeLayerID = id
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
public func setLayerVisibility(id: UUID, isVisible: Bool) {
|
||||
guard let index = layers.firstIndex(where: { $0.id == id }) else { return }
|
||||
layers[index].isVisible = isVisible
|
||||
if activeLayerID == id, !isVisible { activeLayerID = layers.first(where: \.isVisible)?.id }
|
||||
didChangePaths()
|
||||
}
|
||||
|
||||
public func deleteLayer(id: UUID) {
|
||||
guard layers.count > 1, let index = layers.firstIndex(where: { $0.id == id }) else { return }
|
||||
paths.removeAll { $0.layerID == id }
|
||||
layers.remove(at: index)
|
||||
if activeLayerID == id { activeLayerID = layers.first(where: \.isVisible)?.id ?? layers.first?.id }
|
||||
didChangePaths()
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
guard !paths.isEmpty else { return }
|
||||
record(.clear(paths))
|
||||
paths.removeAll()
|
||||
didChangePaths()
|
||||
}
|
||||
|
||||
public func undo() { apply(undo: true) }
|
||||
public func redo() { apply(undo: false) }
|
||||
|
||||
public func beginExternalStroke(at point: CGPoint) {
|
||||
ensureActiveLayer()
|
||||
currentPath = newPath(at: point)
|
||||
}
|
||||
|
||||
public func updateExternalStroke(at point: CGPoint) {
|
||||
guard var path = currentPath else { return }
|
||||
let point = clampedPoint(point)
|
||||
// 保留足够的采样密度,同时丢弃手指静止时的亚像素抖动。
|
||||
guard path.points.last.map({ hypot(point.x - $0.x, point.y - $0.y) >= 1.25 }) ?? true else { return }
|
||||
path.points.append(point)
|
||||
currentPath = path
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
public func endExternalStroke(at point: CGPoint) {
|
||||
updateExternalStroke(at: point)
|
||||
commitCurrentStroke()
|
||||
}
|
||||
|
||||
public override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||
// 图层必须隔离合成:橡皮擦只清除所属图层,不能穿透到下方图层。
|
||||
layers.filter(\.isVisible).forEach { layer in
|
||||
context.saveGState()
|
||||
context.beginTransparencyLayer(auxiliaryInfo: nil)
|
||||
paths.filter { $0.layerID == layer.id }.forEach { draw(path: $0, in: context) }
|
||||
if let currentPath, currentPath.layerID == layer.id { draw(path: currentPath, in: context) }
|
||||
context.endTransparencyLayer()
|
||||
context.restoreGState()
|
||||
}
|
||||
// 兼容极旧的、尚未迁移到默认图层的内存笔迹。
|
||||
paths.filter { $0.layerID == nil }.forEach { draw(path: $0, in: context) }
|
||||
if let currentPath, currentPath.layerID == nil { draw(path: currentPath, in: context) }
|
||||
}
|
||||
|
||||
public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard activeTouchCount(in: event) == 1, let touch = touches.first else { cancelCurrentStroke(); return }
|
||||
activeDrawingTouch = touch
|
||||
let point = clampedPoint(touch.location(in: self))
|
||||
currentPath = newPath(at: point)
|
||||
strokeEventHandler?(touch.location(in: self), .began)
|
||||
}
|
||||
|
||||
public override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard activeTouchCount(in: event) == 1, let activeDrawingTouch, touches.contains(where: { $0 === activeDrawingTouch }) else { cancelCurrentStroke(); return }
|
||||
appendPoints(from: activeDrawingTouch, event: event)
|
||||
strokeEventHandler?(activeDrawingTouch.location(in: self), .moved)
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
public override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let activeDrawingTouch, touches.contains(where: { $0 === activeDrawingTouch }) else { return }
|
||||
defer { self.activeDrawingTouch = nil }
|
||||
appendPoints(from: activeDrawingTouch, event: event)
|
||||
strokeEventHandler?(activeDrawingTouch.location(in: self), .ended)
|
||||
commitCurrentStroke()
|
||||
}
|
||||
|
||||
public override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
if let touch = activeDrawingTouch { strokeEventHandler?(touch.location(in: self), .cancelled) }
|
||||
cancelCurrentStroke()
|
||||
}
|
||||
|
||||
private enum DrawingAction {
|
||||
case add(RDPDFReaderDrawingPath)
|
||||
case remove([RDPDFReaderDrawingPath])
|
||||
case clear([RDPDFReaderDrawingPath])
|
||||
}
|
||||
|
||||
private func record(_ action: DrawingAction) {
|
||||
undoStack.append(action)
|
||||
if undoStack.count > maxUndoCount { undoStack.removeFirst() }
|
||||
redoStack.removeAll()
|
||||
}
|
||||
|
||||
private func apply(undo isUndo: Bool) {
|
||||
var from = isUndo ? undoStack : redoStack
|
||||
guard let action = from.popLast() else { return }
|
||||
if isUndo { undoStack = from; redoStack.append(action) } else { redoStack = from; undoStack.append(action) }
|
||||
switch action {
|
||||
case .add(let path):
|
||||
if isUndo { paths.removeAll { $0.id == path.id } } else { paths.append(path) }
|
||||
case .remove(let removed):
|
||||
if isUndo { paths.append(contentsOf: removed) } else { paths.removeAll { path in removed.contains(where: { $0.id == path.id }) } }
|
||||
case .clear(let saved):
|
||||
if isUndo { paths = saved } else { paths.removeAll() }
|
||||
}
|
||||
didChangePaths()
|
||||
}
|
||||
|
||||
private func appendPoints(from touch: UITouch, event: UIEvent?) {
|
||||
guard var path = currentPath else { return }
|
||||
for sample in event?.coalescedTouches(for: touch) ?? [touch] {
|
||||
let point = clampedPoint(sample.location(in: self))
|
||||
guard path.points.last.map({ hypot(point.x - $0.x, point.y - $0.y) >= 1.25 }) ?? true else { continue }
|
||||
path.points.append(point)
|
||||
}
|
||||
currentPath = path
|
||||
}
|
||||
|
||||
private func activeTouchCount(in event: UIEvent?) -> Int { event?.allTouches?.filter { $0.phase != .ended && $0.phase != .cancelled }.count ?? 0 }
|
||||
private func commitCurrentStroke() {
|
||||
guard let path = currentPath, path.points.count >= (path.tool == .eraser ? 1 : 2) else { cancelCurrentStroke(); return }
|
||||
paths.append(path)
|
||||
record(.add(path))
|
||||
currentPath = nil
|
||||
didChangePaths()
|
||||
}
|
||||
private func cancelCurrentStroke() { currentPath = nil; activeDrawingTouch = nil; setNeedsDisplay() }
|
||||
private func didChangePaths() { setNeedsDisplay(); pathsChangedHandler?(paths) }
|
||||
|
||||
private func draw(path: RDPDFReaderDrawingPath, in context: CGContext) {
|
||||
guard !path.points.isEmpty else { return }
|
||||
context.setBlendMode(path.tool == .eraser ? .clear : .normal)
|
||||
context.setStrokeColor(UIColor(hexString: path.color).cgColor)
|
||||
context.setFillColor(UIColor(hexString: path.color).cgColor)
|
||||
context.setLineWidth(path.tool == .eraser ? eraserLineWidth(for: path) : path.lineWidth)
|
||||
context.setLineCap(.round)
|
||||
context.setLineJoin(.round)
|
||||
context.setAlpha(path.tool == .highlighter ? 0.3 : 1)
|
||||
if path.points.count == 1 {
|
||||
let radius = (path.tool == .eraser ? eraserLineWidth(for: path) : path.lineWidth) / 2
|
||||
let point = path.points[0]
|
||||
context.fillEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2))
|
||||
context.setAlpha(1)
|
||||
context.setBlendMode(.normal)
|
||||
return
|
||||
}
|
||||
context.beginPath()
|
||||
addSmoothedCurve(for: path.points, to: context)
|
||||
context.strokePath()
|
||||
context.setAlpha(1)
|
||||
context.setBlendMode(.normal)
|
||||
}
|
||||
|
||||
private func clampedPoint(_ point: CGPoint) -> CGPoint {
|
||||
CGPoint(x: min(max(point.x, bounds.minX), bounds.maxX), y: min(max(point.y, bounds.minY), bounds.maxY))
|
||||
}
|
||||
|
||||
private func ensureActiveLayer() {
|
||||
guard activeLayerID == nil || !layers.contains(where: { $0.id == activeLayerID && $0.isVisible }) else { return }
|
||||
if let visible = layers.first(where: \.isVisible) { activeLayerID = visible.id }
|
||||
else { activeLayerID = addLayer().id }
|
||||
}
|
||||
|
||||
private func newPath(at point: CGPoint) -> RDPDFReaderDrawingPath {
|
||||
ensureActiveLayer()
|
||||
return .init(
|
||||
page: currentPage,
|
||||
color: strokeColor.hexString,
|
||||
lineWidth: tool == .highlighter ? 8 : lineWidth,
|
||||
tool: tool,
|
||||
layerID: activeLayerID,
|
||||
points: [clampedPoint(point)]
|
||||
)
|
||||
}
|
||||
|
||||
private func eraserLineWidth(for path: RDPDFReaderDrawingPath) -> CGFloat {
|
||||
// SheetMusic 的橡皮与画笔共享大小滑杆;在 UIKit 点坐标系中给出可感知的对应直径。
|
||||
max(path.lineWidth * 2, 12)
|
||||
}
|
||||
|
||||
/// 触点使用 Catmull–Rom 到三次贝塞尔的转换。这样保留原始笔迹数据和撤销语义,
|
||||
/// 显示/导出时却不会出现逐点连线的折角。
|
||||
private func addSmoothedCurve(for points: [CGPoint], to context: CGContext) {
|
||||
guard let first = points.first else { return }
|
||||
context.move(to: first)
|
||||
guard points.count > 1 else { return }
|
||||
guard points.count > 2 else {
|
||||
context.addLine(to: points[1])
|
||||
return
|
||||
}
|
||||
for index in 0..<(points.count - 1) {
|
||||
let p0 = points[max(0, index - 1)]
|
||||
let p1 = points[index]
|
||||
let p2 = points[index + 1]
|
||||
let p3 = points[min(points.count - 1, index + 2)]
|
||||
// 系数 0.5 是标准 Catmull–Rom 张力,换算成三次贝塞尔控制点后为 1 / 6。
|
||||
let control1 = CGPoint(x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6)
|
||||
let control2 = CGPoint(x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6)
|
||||
context.addCurve(to: p2, control1: control1, control2: control2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
convenience init(hexString: String) {
|
||||
let hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "#", with: "")
|
||||
let value = UInt32(hex, radix: 16) ?? 0
|
||||
self.init(red: CGFloat((value >> 16) & 0xFF) / 255, green: CGFloat((value >> 8) & 0xFF) / 255, blue: CGFloat(value & 0xFF) / 255, alpha: 1)
|
||||
}
|
||||
var hexString: String {
|
||||
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
|
||||
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
return String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// SheetMusic 风格的深色图层浮层。它只展示与分发操作,图层数据仍由页面画布维护。
|
||||
final class RDPDFReaderDrawingLayersView: UIView {
|
||||
var addHandler: (() -> Void)?
|
||||
var selectHandler: ((UUID) -> Void)?
|
||||
var visibilityHandler: ((UUID, Bool) -> Void)?
|
||||
var deleteHandler: ((UUID) -> Void)?
|
||||
|
||||
private let dimView = UIControl()
|
||||
private let panel = UIView()
|
||||
private let stack = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.22)
|
||||
dimView.addTarget(self, action: #selector(dismiss), for: .touchUpInside)
|
||||
addSubview(dimView)
|
||||
dimView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
|
||||
panel.backgroundColor = UIColor(red: 0.16, green: 0.16, blue: 0.16, alpha: 0.99)
|
||||
panel.layer.cornerRadius = 14
|
||||
panel.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
addSubview(panel)
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 1
|
||||
panel.addSubview(stack)
|
||||
stack.snp.makeConstraints { $0.edges.equalToSuperview().inset(14) }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func show(in parent: UIView, above toolbar: UIView) {
|
||||
parent.addSubview(self)
|
||||
snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
panel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(toolbar.snp.top)
|
||||
}
|
||||
alpha = 0
|
||||
UIView.animate(withDuration: 0.2) { self.alpha = 1 }
|
||||
}
|
||||
|
||||
func reload(layers: [RDPDFReaderDrawingLayer], selectedLayerID: UUID?) {
|
||||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
let header = UIStackView()
|
||||
header.axis = .horizontal
|
||||
let title = UILabel()
|
||||
title.text = "图层"
|
||||
title.textColor = .white
|
||||
title.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
let add = UIButton(type: .system)
|
||||
add.setImage(UIImage(systemName: "plus.circle.fill"), for: .normal)
|
||||
add.tintColor = UIColor(red: 0.45, green: 0.52, blue: 1, alpha: 1)
|
||||
add.addTarget(self, action: #selector(addLayer), for: .touchUpInside)
|
||||
header.addArrangedSubview(title)
|
||||
header.addArrangedSubview(UIView())
|
||||
header.addArrangedSubview(add)
|
||||
stack.addArrangedSubview(header)
|
||||
header.snp.makeConstraints { $0.height.equalTo(34) }
|
||||
layers.forEach { layer in stack.addArrangedSubview(makeRow(layer, selected: layer.id == selectedLayerID, deletable: layers.count > 1)) }
|
||||
}
|
||||
|
||||
private func makeRow(_ layer: RDPDFReaderDrawingLayer, selected: Bool, deletable: Bool) -> UIView {
|
||||
let row = UIControl()
|
||||
row.backgroundColor = selected ? UIColor(red: 0.30, green: 0.34, blue: 0.58, alpha: 1) : UIColor.white.withAlphaComponent(0.07)
|
||||
row.layer.cornerRadius = 8
|
||||
row.tag = layer.id.hashValue
|
||||
row.addAction(UIAction { [weak self] _ in self?.selectHandler?(layer.id) }, for: .touchUpInside)
|
||||
let eye = UIButton(type: .system)
|
||||
eye.setImage(UIImage(systemName: layer.isVisible ? "eye" : "eye.slash"), for: .normal)
|
||||
eye.tintColor = .white
|
||||
eye.addAction(UIAction { [weak self] _ in self?.visibilityHandler?(layer.id, !layer.isVisible) }, for: .touchUpInside)
|
||||
let name = UILabel()
|
||||
name.text = layer.name
|
||||
name.textColor = .white
|
||||
name.font = .systemFont(ofSize: 14)
|
||||
let delete = UIButton(type: .system)
|
||||
delete.setImage(UIImage(systemName: "trash"), for: .normal)
|
||||
delete.tintColor = deletable ? UIColor.systemRed : UIColor.white.withAlphaComponent(0.22)
|
||||
delete.isEnabled = deletable
|
||||
delete.addAction(UIAction { [weak self] _ in self?.deleteHandler?(layer.id) }, for: .touchUpInside)
|
||||
[eye, name, delete].forEach(row.addSubview)
|
||||
eye.snp.makeConstraints { make in make.leading.equalToSuperview().offset(12); make.centerY.equalToSuperview(); make.size.equalTo(24) }
|
||||
name.snp.makeConstraints { make in make.leading.equalTo(eye.snp.trailing).offset(10); make.centerY.equalToSuperview() }
|
||||
delete.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(10); make.centerY.equalToSuperview(); make.size.equalTo(24) }
|
||||
row.snp.makeConstraints { $0.height.equalTo(42) }
|
||||
return row
|
||||
}
|
||||
|
||||
@objc private func addLayer() { addHandler?() }
|
||||
@objc private func dismiss() { removeFromSuperview() }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 对齐 SheetMusic 的画笔工具栏:上方为尺寸与颜色,下方为笔刷、橡皮、图层及撤销重做。
|
||||
/// 工具栏只表达 UI 状态,不持有任何 PDF 页或笔迹数据。
|
||||
public final class RDPDFReaderDrawingToolbar: UIView {
|
||||
public var toolChangedHandler: ((RDPDFReaderDrawingTool) -> Void)?
|
||||
public var colorChangedHandler: ((UIColor) -> Void)?
|
||||
public var lineWidthChangedHandler: ((CGFloat) -> Void)?
|
||||
public var undoHandler: (() -> Void)?
|
||||
public var redoHandler: (() -> Void)?
|
||||
public var clearHandler: (() -> Void)?
|
||||
public var layersHandler: (() -> Void)?
|
||||
public var doneHandler: (() -> Void)?
|
||||
|
||||
private let sizeSlider = UISlider()
|
||||
private let sizeValueLabel = UILabel()
|
||||
private let colors: [UIColor] = [.white, .black, UIColor(red: 0.87, green: 0.17, blue: 0.17, alpha: 1), UIColor(red: 1, green: 0.56, blue: 0.16, alpha: 1), UIColor(red: 0.96, green: 0.89, blue: 0.20, alpha: 1), UIColor(red: 0.24, green: 0.76, blue: 0.24, alpha: 1), UIColor(red: 0.12, green: 0.83, blue: 0.93, alpha: 1), UIColor(red: 0.21, green: 0.36, blue: 0.90, alpha: 1), UIColor(red: 0.68, green: 0.36, blue: 1, alpha: 1), UIColor(red: 0.99, green: 0.40, blue: 0.61, alpha: 1)]
|
||||
private var colorButtons: [UIButton] = []
|
||||
private var selectedTool: RDPDFReaderDrawingTool = .pen
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor(red: 0.20, green: 0.20, blue: 0.20, alpha: 0.98)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
private func setupViews() {
|
||||
let sizeLabel = UILabel()
|
||||
sizeLabel.text = "大小"
|
||||
sizeLabel.font = .systemFont(ofSize: 13)
|
||||
sizeLabel.textColor = .white
|
||||
addSubview(sizeLabel)
|
||||
sizeLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(22)
|
||||
make.top.equalToSuperview().offset(12)
|
||||
}
|
||||
|
||||
sizeSlider.minimumValue = 1
|
||||
sizeSlider.maximumValue = 10
|
||||
sizeSlider.value = 4
|
||||
sizeSlider.minimumTrackTintColor = UIColor(white: 0.58, alpha: 1)
|
||||
sizeSlider.maximumTrackTintColor = UIColor(white: 0.58, alpha: 1)
|
||||
sizeSlider.thumbTintColor = .white
|
||||
sizeSlider.addTarget(self, action: #selector(sizeChanged), for: .valueChanged)
|
||||
addSubview(sizeSlider)
|
||||
sizeSlider.snp.makeConstraints { make in
|
||||
make.leading.equalTo(sizeLabel.snp.trailing).offset(14)
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.height.equalTo(30)
|
||||
make.trailing.equalToSuperview().inset(58)
|
||||
}
|
||||
|
||||
sizeValueLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular)
|
||||
sizeValueLabel.textColor = .white
|
||||
sizeValueLabel.textAlignment = .center
|
||||
sizeValueLabel.text = "40"
|
||||
addSubview(sizeValueLabel)
|
||||
sizeValueLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(sizeSlider)
|
||||
make.leading.equalTo(sizeSlider.snp.trailing).offset(5)
|
||||
make.trailing.equalToSuperview().inset(12)
|
||||
}
|
||||
|
||||
let colorStack = UIStackView()
|
||||
colorStack.axis = .horizontal
|
||||
colorStack.alignment = .center
|
||||
colorStack.spacing = 13
|
||||
addSubview(colorStack)
|
||||
colorStack.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(20)
|
||||
make.top.equalTo(sizeSlider.snp.bottom).offset(8)
|
||||
make.height.equalTo(30)
|
||||
}
|
||||
for (index, color) in colors.enumerated() {
|
||||
let button = UIButton(type: .custom)
|
||||
button.backgroundColor = color
|
||||
button.layer.cornerRadius = 12
|
||||
button.layer.borderWidth = index == 1 ? 2 : 0
|
||||
button.layer.borderColor = UIColor.systemYellow.cgColor
|
||||
button.tag = index
|
||||
button.addTarget(self, action: #selector(colorTapped(_:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { $0.size.equalTo(24) }
|
||||
colorStack.addArrangedSubview(button)
|
||||
colorButtons.append(button)
|
||||
}
|
||||
|
||||
let toolStack = UIStackView()
|
||||
toolStack.axis = .horizontal
|
||||
toolStack.spacing = 24
|
||||
toolStack.alignment = .center
|
||||
addSubview(toolStack)
|
||||
toolStack.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(22)
|
||||
make.top.equalTo(colorStack.snp.bottom).offset(9)
|
||||
make.bottom.equalTo(safeAreaLayoutGuide).inset(9)
|
||||
make.height.equalTo(32)
|
||||
}
|
||||
toolStack.addArrangedSubview(makeIconButton(symbol: "pencil", action: #selector(penTapped), selected: true))
|
||||
toolStack.addArrangedSubview(makeIconButton(symbol: "eraser", action: #selector(eraserTapped)))
|
||||
toolStack.addArrangedSubview(makeIconButton(symbol: "square.3.layers.3d", action: #selector(layersTapped)))
|
||||
|
||||
let clearButton = makeTextButton("清空", action: #selector(clearTapped))
|
||||
addSubview(clearButton)
|
||||
clearButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(toolStack)
|
||||
make.trailing.equalToSuperview().inset(134)
|
||||
}
|
||||
let undo = makeIconButton(symbol: "arrow.uturn.backward", action: #selector(undoTapped))
|
||||
let redo = makeIconButton(symbol: "arrow.uturn.forward", action: #selector(redoTapped))
|
||||
let done = makeTextButton("完成", action: #selector(doneTapped))
|
||||
[undo, redo, done].forEach(addSubview)
|
||||
done.snp.makeConstraints { make in make.centerY.equalTo(toolStack); make.trailing.equalToSuperview().inset(16) }
|
||||
redo.snp.makeConstraints { make in make.centerY.equalTo(toolStack); make.trailing.equalTo(done.snp.leading).offset(-16) }
|
||||
undo.snp.makeConstraints { make in make.centerY.equalTo(toolStack); make.trailing.equalTo(redo.snp.leading).offset(-20) }
|
||||
}
|
||||
|
||||
private func makeIconButton(symbol: String, action: Selector, selected: Bool = false) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setImage(UIImage(systemName: symbol), for: .normal)
|
||||
button.tintColor = selected ? UIColor(red: 0.45, green: 0.52, blue: 1, alpha: 1) : .white
|
||||
button.contentEdgeInsets = .init(top: 3, left: 3, bottom: 3, right: 3)
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
button.snp.makeConstraints { $0.size.equalTo(28) }
|
||||
return button
|
||||
}
|
||||
|
||||
private func makeTextButton(_ title: String, action: Selector) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func sizeChanged() {
|
||||
sizeValueLabel.text = "\(Int(ceil(sizeSlider.value * 10)))"
|
||||
lineWidthChangedHandler?(CGFloat(sizeSlider.value))
|
||||
}
|
||||
@objc private func colorTapped(_ sender: UIButton) {
|
||||
colorButtons.enumerated().forEach { index, button in
|
||||
button.layer.borderWidth = index == sender.tag ? 2 : 0
|
||||
button.layer.borderColor = UIColor.systemYellow.cgColor
|
||||
}
|
||||
colorChangedHandler?(colors[sender.tag])
|
||||
}
|
||||
@objc private func penTapped() { selectTool(.pen) }
|
||||
@objc private func eraserTapped() { selectTool(.eraser) }
|
||||
@objc private func layersTapped() { layersHandler?() }
|
||||
@objc private func undoTapped() { undoHandler?() }
|
||||
@objc private func redoTapped() { redoHandler?() }
|
||||
@objc private func clearTapped() { clearHandler?() }
|
||||
@objc private func doneTapped() { doneHandler?() }
|
||||
|
||||
private func selectTool(_ tool: RDPDFReaderDrawingTool) {
|
||||
selectedTool = tool
|
||||
toolChangedHandler?(tool)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,199 @@
|
||||
import CoreImage
|
||||
import UIKit
|
||||
import Vision
|
||||
|
||||
/// 为只有页面图片的宿主提供按需 OCR。识别结果是行级 `RDPDFReaderTextRun`,
|
||||
/// 可直接用于复制、文字高亮和文字选区。
|
||||
///
|
||||
/// 回调始终回到主线程;识别工作本身在内部串行队列执行,避免连续翻页时同时发起过多
|
||||
/// Vision 请求占用 CPU。
|
||||
public final class RDPDFReaderImageTextRecognizer {
|
||||
public typealias Completion = ([RDPDFReaderTextRun]) -> Void
|
||||
|
||||
/// 默认使用准确模式,适合中文等阅读场景;追求响应速度时可改为 `.fast`。
|
||||
public var recognitionLevel: VNRequestTextRecognitionLevel
|
||||
/// 留空时由 Vision 自动选择可用语言。宿主也可显式传入,例如 `["zh-Hans", "en-US"]`。
|
||||
public var recognitionLanguages: [String]
|
||||
public var usesLanguageCorrection: Bool
|
||||
|
||||
private let processingQueue = DispatchQueue(
|
||||
label: "com.readoor.pdf-reader.image-text-recognizer",
|
||||
qos: .userInitiated
|
||||
)
|
||||
|
||||
public init(
|
||||
recognitionLevel: VNRequestTextRecognitionLevel = .accurate,
|
||||
recognitionLanguages: [String] = [],
|
||||
usesLanguageCorrection: Bool = true
|
||||
) {
|
||||
self.recognitionLevel = recognitionLevel
|
||||
self.recognitionLanguages = recognitionLanguages
|
||||
self.usesLanguageCorrection = usesLanguageCorrection
|
||||
}
|
||||
|
||||
/// 异步识别页面图片中的文字。结果的矩形以图片左上角为原点,范围为 0...1。
|
||||
public func recognizeTextRuns(in image: UIImage, completion: @escaping Completion) {
|
||||
guard let cgImage = Self.makeCGImage(from: image) else {
|
||||
deliver([], to: completion)
|
||||
return
|
||||
}
|
||||
|
||||
let configuration = Configuration(
|
||||
recognitionLevel: recognitionLevel,
|
||||
recognitionLanguages: recognitionLanguages,
|
||||
usesLanguageCorrection: usesLanguageCorrection
|
||||
)
|
||||
let orientation = CGImagePropertyOrientation(orientation: image.imageOrientation)
|
||||
|
||||
processingQueue.async {
|
||||
let request = VNRecognizeTextRequest { request, _ in
|
||||
let observations = request.results as? [VNRecognizedTextObservation] ?? []
|
||||
let runs = Self.makeTextRuns(from: observations)
|
||||
self.deliver(runs, to: completion)
|
||||
}
|
||||
request.recognitionLevel = configuration.recognitionLevel
|
||||
request.recognitionLanguages = configuration.recognitionLanguages
|
||||
request.usesLanguageCorrection = configuration.usesLanguageCorrection
|
||||
|
||||
do {
|
||||
try VNImageRequestHandler(cgImage: cgImage, orientation: orientation).perform([request])
|
||||
} catch {
|
||||
self.deliver([], to: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Swift Concurrency 版本,与回调版本使用相同的识别和主线程回调语义。
|
||||
@available(iOS 15.0, *)
|
||||
public func recognizeTextRuns(in image: UIImage) async -> [RDPDFReaderTextRun] {
|
||||
await withCheckedContinuation { continuation in
|
||||
recognizeTextRuns(in: image) { runs in
|
||||
continuation.resume(returning: runs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Configuration {
|
||||
let recognitionLevel: VNRequestTextRecognitionLevel
|
||||
let recognitionLanguages: [String]
|
||||
let usesLanguageCorrection: Bool
|
||||
}
|
||||
|
||||
private struct RecognizedLine {
|
||||
let text: String
|
||||
let normalizedRect: CGRect
|
||||
let characterRects: [CGRect]?
|
||||
}
|
||||
|
||||
private static func makeTextRuns(from observations: [VNRecognizedTextObservation]) -> [RDPDFReaderTextRun] {
|
||||
let lines = observations.compactMap { observation -> RecognizedLine? in
|
||||
guard let candidate = observation.topCandidates(1).first else { return nil }
|
||||
let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, let rect = normalizedUIKitRect(fromVisionRect: observation.boundingBox) else {
|
||||
return nil
|
||||
}
|
||||
return RecognizedLine(
|
||||
text: text,
|
||||
normalizedRect: rect,
|
||||
characterRects: makeCharacterRects(from: candidate, trimmedText: text)
|
||||
)
|
||||
}
|
||||
|
||||
return lines
|
||||
.sorted(by: isBeforeInReadingOrder)
|
||||
.enumerated()
|
||||
.map { index, line in
|
||||
RDPDFReaderTextRun(
|
||||
text: line.text,
|
||||
normalizedRects: [line.normalizedRect],
|
||||
readingOrder: index,
|
||||
characterRects: line.characterRects
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐组合字符询问 Vision 的字符外接框,供字符级选区使用。
|
||||
/// 任一字符取不到坐标时整行返回 `nil`,由文本层退回均分估算。
|
||||
private static func makeCharacterRects(from candidate: VNRecognizedText, trimmedText: String) -> [CGRect]? {
|
||||
let original = candidate.string
|
||||
guard let trimmedRange = original.range(of: trimmedText) else { return nil }
|
||||
|
||||
var rects: [CGRect] = []
|
||||
var index = trimmedRange.lowerBound
|
||||
while index < trimmedRange.upperBound {
|
||||
let next = original.index(after: index)
|
||||
guard let observation = try? candidate.boundingBox(for: index..<next),
|
||||
let rect = normalizedUIKitRect(fromVisionRect: observation.boundingBox) else {
|
||||
return nil
|
||||
}
|
||||
rects.append(rect)
|
||||
index = next
|
||||
}
|
||||
return rects.isEmpty ? nil : rects
|
||||
}
|
||||
|
||||
/// Vision 使用左下角为原点;阅读器其余 UI 使用 UIKit 左上角为原点。
|
||||
private static func normalizedUIKitRect(fromVisionRect rect: CGRect) -> CGRect? {
|
||||
let minX = clamp(rect.minX)
|
||||
let maxX = clamp(rect.maxX)
|
||||
let minY = clamp(1 - rect.maxY)
|
||||
let maxY = clamp(1 - rect.minY)
|
||||
guard maxX > minX, maxY > minY else { return nil }
|
||||
return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
|
||||
}
|
||||
|
||||
private static func isBeforeInReadingOrder(_ lhs: RecognizedLine, _ rhs: RecognizedLine) -> Bool {
|
||||
// 同一视觉行内按从左到右排序;不同行则按从上到下排序。
|
||||
let verticalTolerance = max(min(lhs.normalizedRect.height, rhs.normalizedRect.height) * 0.5, 0.01)
|
||||
if abs(lhs.normalizedRect.midY - rhs.normalizedRect.midY) <= verticalTolerance {
|
||||
if lhs.normalizedRect.minX != rhs.normalizedRect.minX {
|
||||
return lhs.normalizedRect.minX < rhs.normalizedRect.minX
|
||||
}
|
||||
return lhs.normalizedRect.minY < rhs.normalizedRect.minY
|
||||
}
|
||||
return lhs.normalizedRect.minY < rhs.normalizedRect.minY
|
||||
}
|
||||
|
||||
private static func clamp(_ value: CGFloat) -> CGFloat {
|
||||
min(max(value, 0), 1)
|
||||
}
|
||||
|
||||
private static func makeCGImage(from image: UIImage) -> CGImage? {
|
||||
if let cgImage = image.cgImage {
|
||||
return cgImage
|
||||
}
|
||||
guard let ciImage = image.ciImage else { return nil }
|
||||
return CIContext(options: nil).createCGImage(ciImage, from: ciImage.extent)
|
||||
}
|
||||
|
||||
private func deliver(_ runs: [RDPDFReaderTextRun], to completion: @escaping Completion) {
|
||||
DispatchQueue.main.async {
|
||||
completion(runs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGImagePropertyOrientation {
|
||||
init(orientation: UIImage.Orientation) {
|
||||
switch orientation {
|
||||
case .up:
|
||||
self = .up
|
||||
case .upMirrored:
|
||||
self = .upMirrored
|
||||
case .down:
|
||||
self = .down
|
||||
case .downMirrored:
|
||||
self = .downMirrored
|
||||
case .left:
|
||||
self = .left
|
||||
case .leftMirrored:
|
||||
self = .leftMirrored
|
||||
case .right:
|
||||
self = .right
|
||||
case .rightMirrored:
|
||||
self = .rightMirrored
|
||||
@unknown default:
|
||||
self = .up
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import UIKit
|
||||
|
||||
public protocol RDPDFReaderKitViewDataSource: AnyObject {
|
||||
func numberOfPages(in readerView: RDPDFReaderKitView) -> Int
|
||||
func readerView(_ readerView: RDPDFReaderKitView, imageForPageAt index: Int) -> UIImage?
|
||||
}
|
||||
|
||||
public protocol RDPDFReaderKitViewDelegate: AnyObject {
|
||||
func readerView(_ readerView: RDPDFReaderKitView, didMoveToPage index: Int)
|
||||
}
|
||||
|
||||
/// 独立 PDF 阅读容器:仅处理分页和页面缩放,不依赖主工程的下载/缓存/数据库。
|
||||
public final class RDPDFReaderKitView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
public weak var dataSource: RDPDFReaderKitViewDataSource?
|
||||
public weak var delegate: RDPDFReaderKitViewDelegate?
|
||||
public private(set) var currentPage = 0
|
||||
|
||||
/// 业务层可把画笔叠加到 `RDPDFZoomablePageView.contentView`;此状态禁止单指翻页,
|
||||
/// 同时保留页面的双指缩放和双指拖动。
|
||||
public var isDrawingMode = false {
|
||||
didSet { updatePagingInteraction() }
|
||||
}
|
||||
|
||||
public var onConfigurePage: ((RDPDFZoomablePageView, Int) -> Void)?
|
||||
|
||||
private let layout: UICollectionViewFlowLayout = {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.scrollDirection = .horizontal
|
||||
layout.minimumLineSpacing = 0
|
||||
layout.minimumInteritemSpacing = 0
|
||||
return layout
|
||||
}()
|
||||
private lazy var collectionView: UICollectionView = {
|
||||
let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
view.backgroundColor = UIColor(white: 0.93, alpha: 1)
|
||||
view.isPagingEnabled = true
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.dataSource = self
|
||||
view.delegate = self
|
||||
view.panGestureRecognizer.maximumNumberOfTouches = 1
|
||||
view.register(PageCell.self, forCellWithReuseIdentifier: PageCell.reuseIdentifier)
|
||||
return view
|
||||
}()
|
||||
private var isCurrentPageZoomed = false
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(collectionView)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
addSubview(collectionView)
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
collectionView.frame = bounds
|
||||
layout.itemSize = bounds.size
|
||||
}
|
||||
|
||||
public func reloadData() {
|
||||
let pageCount = dataSource?.numberOfPages(in: self) ?? 0
|
||||
currentPage = max(0, min(currentPage, max(0, pageCount - 1)))
|
||||
isCurrentPageZoomed = false
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
collectionView.setContentOffset(CGPoint(x: CGFloat(currentPage) * bounds.width, y: 0), animated: false)
|
||||
updatePagingInteraction()
|
||||
}
|
||||
|
||||
public func go(to page: Int, animated: Bool = true) {
|
||||
let count = dataSource?.numberOfPages(in: self) ?? 0
|
||||
guard count > 0 else { return }
|
||||
let target = max(0, min(page, count - 1))
|
||||
guard !isDrawingMode, !isCurrentPageZoomed else { return }
|
||||
currentPage = target
|
||||
collectionView.setContentOffset(CGPoint(x: CGFloat(target) * bounds.width, y: 0), animated: animated)
|
||||
delegate?.readerView(self, didMoveToPage: target)
|
||||
}
|
||||
|
||||
public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
dataSource?.numberOfPages(in: self) ?? 0
|
||||
}
|
||||
|
||||
public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PageCell.reuseIdentifier, for: indexPath) as! PageCell
|
||||
let pageView = cell.pageView
|
||||
pageView.image = dataSource?.readerView(self, imageForPageAt: indexPath.item)
|
||||
pageView.isDrawingMode = isDrawingMode
|
||||
pageView.zoomStateChanged = { [weak self, weak pageView] zoomed in
|
||||
guard let self, let pageView, pageView.tag == self.currentPage else { return }
|
||||
self.isCurrentPageZoomed = zoomed
|
||||
self.updatePagingInteraction()
|
||||
}
|
||||
pageView.tag = indexPath.item
|
||||
onConfigurePage?(pageView, indexPath.item)
|
||||
return cell
|
||||
}
|
||||
|
||||
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
updateCurrentPageFromOffset()
|
||||
}
|
||||
|
||||
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
updateCurrentPageFromOffset()
|
||||
}
|
||||
|
||||
private func updateCurrentPageFromOffset() {
|
||||
guard bounds.width > 0 else { return }
|
||||
let page = Int((collectionView.contentOffset.x / bounds.width).rounded())
|
||||
guard page != currentPage else { return }
|
||||
currentPage = page
|
||||
isCurrentPageZoomed = false
|
||||
updatePagingInteraction()
|
||||
delegate?.readerView(self, didMoveToPage: page)
|
||||
}
|
||||
|
||||
private func updatePagingInteraction() {
|
||||
collectionView.isScrollEnabled = !isDrawingMode && !isCurrentPageZoomed
|
||||
collectionView.visibleCells
|
||||
.compactMap { ($0 as? PageCell)?.pageView }
|
||||
.forEach { $0.isDrawingMode = isDrawingMode }
|
||||
}
|
||||
}
|
||||
|
||||
private final class PageCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "RDPDFReaderKitView.PageCell"
|
||||
let pageView = RDPDFZoomablePageView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.addSubview(pageView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
pageView.frame = contentView.bounds
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
pageView.resetZoom()
|
||||
pageView.zoomStateChanged = nil
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 目录/书签/缩略图面板:给 `RDPDFReaderNavigationView` 加上把手、
|
||||
/// 关闭按钮和圆角容器,配合 `RDPDFReaderPanelPresenter` 以底部面板呈现。
|
||||
@@ -37,10 +38,7 @@ public final class RDPDFReaderNavigationPanelViewController: UIViewController {
|
||||
view.layer.masksToBounds = true
|
||||
view.accessibilityIdentifier = "pdf.reader.navigation.panel"
|
||||
|
||||
[handleView, closeButton, navigationView].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview($0)
|
||||
}
|
||||
[handleView, closeButton, navigationView].forEach(view.addSubview)
|
||||
|
||||
handleView.layer.cornerRadius = 3
|
||||
handleView.backgroundColor = UIColor.label.withAlphaComponent(0.35)
|
||||
@@ -49,22 +47,21 @@ public final class RDPDFReaderNavigationPanelViewController: UIViewController {
|
||||
closeButton.accessibilityIdentifier = "pdf.reader.navigation.done"
|
||||
closeButton.addTarget(self, action: #selector(doneAction), for: .touchUpInside)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
handleView.topAnchor.constraint(equalTo: view.topAnchor, constant: 10),
|
||||
handleView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
handleView.widthAnchor.constraint(equalToConstant: 42),
|
||||
handleView.heightAnchor.constraint(equalToConstant: 5),
|
||||
|
||||
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
closeButton.centerYAnchor.constraint(equalTo: handleView.centerYAnchor, constant: 20),
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
|
||||
navigationView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
navigationView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
navigationView.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 14),
|
||||
navigationView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
|
||||
])
|
||||
handleView.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(10)
|
||||
make.centerX.equalToSuperview()
|
||||
make.size.equalTo(CGSize(width: 42, height: 5))
|
||||
}
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalTo(handleView.snp.centerY).offset(20)
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
navigationView.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview()
|
||||
make.top.equalTo(handleView.snp.bottom).offset(14)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
navigationView.outlineItems = outlineItems
|
||||
navigationView.bookmarks = bookmarks
|
||||
navigationView.configure(tab: .catalog, totalPages: totalPages, thumbnailProvider: thumbnailProvider)
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// SDK 阅读导航页:目录、缩略图、书签。内容和缩略图均由宿主异步提供。
|
||||
public final class RDPDFReaderNavigationView: UIView {
|
||||
public enum Tab: Int {
|
||||
case catalog
|
||||
case thumbnails
|
||||
case bookmarks
|
||||
}
|
||||
|
||||
public var outlineItems: [RDPDFReaderOutlineItem] = [] {
|
||||
didSet { catalogTableView.reloadData() }
|
||||
}
|
||||
public var bookmarks: [RDPDFReaderBookmark] = [] {
|
||||
didSet { bookmarkTableView.reloadData() }
|
||||
}
|
||||
public var itemTapHandler: ((Int) -> Void)?
|
||||
|
||||
private let segment = UISegmentedControl(items: ["目录", "缩略图", "书签"])
|
||||
private let catalogTableView = UITableView(frame: .zero, style: .plain)
|
||||
private let bookmarkTableView = UITableView(frame: .zero, style: .plain)
|
||||
private let thumbnailView = ThumbnailGridView()
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.toc.panel"
|
||||
backgroundColor = .white
|
||||
setupViews()
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public func configure(
|
||||
tab: Tab,
|
||||
totalPages: Int,
|
||||
thumbnailProvider: ((Int, CGSize, @escaping (UIImage?) -> Void) -> Void)?
|
||||
) {
|
||||
thumbnailView.configure(totalPages: totalPages, provider: thumbnailProvider)
|
||||
select(tab)
|
||||
}
|
||||
|
||||
public func select(_ tab: Tab) {
|
||||
segment.selectedSegmentIndex = tab.rawValue
|
||||
updateVisibleContent()
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
segment.accessibilityIdentifier = "pdf.reader.navigation.segment"
|
||||
segment.selectedSegmentIndex = Tab.catalog.rawValue
|
||||
segment.selectedSegmentTintColor = .white
|
||||
segment.backgroundColor = UIColor(white: 0.92, alpha: 1)
|
||||
segment.setTitleTextAttributes([.foregroundColor: UIColor.black], for: .normal)
|
||||
segment.setTitleTextAttributes([.foregroundColor: UIColor.black], for: .selected)
|
||||
segment.addTarget(self, action: #selector(segmentChanged), for: .valueChanged)
|
||||
[segment, catalogTableView, bookmarkTableView, thumbnailView].forEach(addSubview)
|
||||
segment.snp.makeConstraints { make in
|
||||
make.top.equalTo(safeAreaLayoutGuide).offset(20)
|
||||
make.centerX.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualToSuperview().offset(16)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(16)
|
||||
make.width.lessThanOrEqualTo(600)
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
[catalogTableView, bookmarkTableView, thumbnailView].forEach { content in
|
||||
content.snp.makeConstraints { make in
|
||||
make.top.equalTo(segment.snp.bottom).offset(20)
|
||||
make.horizontalEdges.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
[catalogTableView, bookmarkTableView].forEach {
|
||||
$0.dataSource = self
|
||||
$0.delegate = self
|
||||
$0.separatorInset = .zero
|
||||
$0.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
|
||||
}
|
||||
catalogTableView.accessibilityIdentifier = "epub.reader.toc.table"
|
||||
bookmarkTableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||||
thumbnailView.accessibilityIdentifier = "pdf.reader.thumbnails"
|
||||
thumbnailView.pageTapHandler = { [weak self] pageIndex in self?.itemTapHandler?(pageIndex) }
|
||||
updateVisibleContent()
|
||||
}
|
||||
|
||||
@objc private func segmentChanged() { updateVisibleContent() }
|
||||
|
||||
private func updateVisibleContent() {
|
||||
let selected = Tab(rawValue: segment.selectedSegmentIndex) ?? .catalog
|
||||
catalogTableView.isHidden = selected != .catalog
|
||||
thumbnailView.isHidden = selected != .thumbnails
|
||||
bookmarkTableView.isHidden = selected != .bookmarks
|
||||
}
|
||||
}
|
||||
|
||||
extension RDPDFReaderNavigationView: UITableViewDataSource, UITableViewDelegate {
|
||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
tableView === catalogTableView ? outlineItems.count : bookmarks.count
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
|
||||
cell.accessoryType = .disclosureIndicator
|
||||
if tableView === catalogTableView {
|
||||
let item = outlineItems[indexPath.row]
|
||||
cell.indentationLevel = item.level
|
||||
cell.textLabel?.text = "\(item.title) \(item.pageIndex + 1)"
|
||||
cell.textLabel?.font = .systemFont(ofSize: max(12, 16 - CGFloat(item.level) * 1.5))
|
||||
} else {
|
||||
let bookmark = bookmarks[indexPath.row]
|
||||
cell.indentationLevel = 0
|
||||
cell.textLabel?.text = bookmark.title ?? "第 \(bookmark.pageIndex + 1) 页"
|
||||
cell.textLabel?.font = .systemFont(ofSize: 16)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
let pageIndex = tableView === catalogTableView
|
||||
? outlineItems[indexPath.row].pageIndex
|
||||
: bookmarks[indexPath.row].pageIndex
|
||||
itemTapHandler?(pageIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ThumbnailGridView: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
||||
var pageTapHandler: ((Int) -> Void)?
|
||||
private var totalPages = 0
|
||||
private var images: [Int: UIImage] = [:]
|
||||
private var loading = Set<Int>()
|
||||
private var provider: ((Int, CGSize, @escaping (UIImage?) -> Void) -> Void)?
|
||||
private let collectionView: UICollectionView
|
||||
|
||||
override init(frame: CGRect) {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.minimumInteritemSpacing = 8
|
||||
layout.minimumLineSpacing = 8
|
||||
layout.sectionInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
super.init(frame: frame)
|
||||
collectionView.backgroundColor = .white
|
||||
collectionView.dataSource = self
|
||||
collectionView.delegate = self
|
||||
collectionView.register(ThumbnailCell.self, forCellWithReuseIdentifier: "cell")
|
||||
addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { $0.edges.equalToSuperview() }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func configure(totalPages: Int, provider: ((Int, CGSize, @escaping (UIImage?) -> Void) -> Void)?) {
|
||||
self.totalPages = totalPages
|
||||
self.provider = provider
|
||||
images.removeAll()
|
||||
loading.removeAll()
|
||||
collectionView.reloadData()
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { totalPages }
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! ThumbnailCell
|
||||
let index = indexPath.item
|
||||
cell.pageLabel.text = "\(index + 1)"
|
||||
cell.imageView.image = images[index]
|
||||
guard images[index] == nil, !loading.contains(index) else { return cell }
|
||||
loading.insert(index)
|
||||
provider?(index, CGSize(width: 160, height: 224)) { [weak self, weak collectionView] image in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.loading.remove(index)
|
||||
guard let image else { return }
|
||||
self.images[index] = image
|
||||
if let cell = collectionView?.cellForItem(at: indexPath) as? ThumbnailCell {
|
||||
cell.imageView.image = image
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let width = (collectionView.bounds.width - 32) / 3
|
||||
return CGSize(width: width, height: width * 1.4)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
pageTapHandler?(indexPath.item)
|
||||
}
|
||||
}
|
||||
|
||||
private final class ThumbnailCell: UICollectionViewCell {
|
||||
let imageView = UIImageView()
|
||||
let pageLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.clipsToBounds = true
|
||||
imageView.backgroundColor = UIColor.lightGray.withAlphaComponent(0.1)
|
||||
pageLabel.font = .systemFont(ofSize: 11)
|
||||
pageLabel.textAlignment = .center
|
||||
[imageView, pageLabel].forEach(contentView.addSubview)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.horizontalEdges.top.equalToSuperview()
|
||||
make.bottom.equalTo(pageLabel.snp.top)
|
||||
}
|
||||
pageLabel.snp.makeConstraints { make in
|
||||
make.horizontalEdges.bottom.equalToSuperview()
|
||||
make.height.equalTo(20)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
}
|
||||
@@ -57,10 +57,24 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
|
||||
private let zoomView = RDPDFZoomablePageView()
|
||||
private let textLayer = RDPDFReaderImageTextLayerView()
|
||||
private let drawingCanvas = RDPDFReaderDrawingCanvasView()
|
||||
private let contentAccessibilityView = UIView()
|
||||
private let selectionLoupe = RDPDFReaderSelectionLoupeView()
|
||||
private var tappedHighlight: RDPDFReaderAnnotation?
|
||||
|
||||
/// 画笔层属于单个实际 PDF 页,而不是横屏双页容器;因此笔迹不能越过书脊。
|
||||
public var isDrawingMode = false {
|
||||
didSet {
|
||||
// 退出编辑只关闭触摸,不隐藏画布;否则已持久化的笔迹会和工具栏一起消失。
|
||||
drawingCanvas.isHidden = false
|
||||
drawingCanvas.isUserInteractionEnabled = isDrawingMode
|
||||
textLayer.isSelectionEnabled = !isDrawingMode
|
||||
if isDrawingMode { textLayer.clearSelection() }
|
||||
}
|
||||
}
|
||||
|
||||
var drawingStrokeEventHandler: ((RDPDFReaderPageView, CGPoint, RDPDFReaderDrawingStrokePhase) -> Void)?
|
||||
|
||||
// MARK: - RDPDFReaderPageInteractable
|
||||
|
||||
public var readerContentTapHandler: ((CGPoint) -> Void)?
|
||||
@@ -81,6 +95,28 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
public func readerUpdateExternalPan(translation: CGPoint) { zoomView.updateExternalPan(translation: translation) }
|
||||
public func readerEndExternalPan() { zoomView.endExternalPan() }
|
||||
|
||||
func setSpreadContentAlignment(_ alignment: RDPDFZoomSurfaceView.ContentAlignment) {
|
||||
zoomView.contentAlignment = alignment
|
||||
}
|
||||
|
||||
/// 手机横屏单页按安全区内的可用宽度显示;纵向超出的部分由阅读器的
|
||||
/// collectionView 连续竖滑承接(cell 高度即纸张高度),页面内部不做
|
||||
/// 兜底竖向拖动——嵌套的同向滚动会吞掉外层手势,导致无法滚到下一页。
|
||||
func setPhoneLandscapeWidthFitting(_ enabled: Bool, safeAreaInsets: UIEdgeInsets) {
|
||||
zoomView.pageFitMode = enabled ? .fitAvailableWidth : .aspectFit
|
||||
zoomView.allowsBaselineVerticalPan = false
|
||||
zoomView.contentHorizontalInsets = enabled
|
||||
? UIEdgeInsets(top: 0, left: safeAreaInsets.left, bottom: 0, right: safeAreaInsets.right)
|
||||
: .zero
|
||||
}
|
||||
|
||||
/// 页面图片在当前适配比例下的实际纸张区域,用于把仿真翻页手势限制在书籍范围内。
|
||||
func fittedPageFrame(in targetView: UIView) -> CGRect {
|
||||
layoutIfNeeded()
|
||||
zoomView.layoutIfNeeded()
|
||||
return zoomView.contentView.convert(zoomView.contentView.bounds, to: targetView)
|
||||
}
|
||||
|
||||
// MARK: - 配置
|
||||
|
||||
public func configureTextLayer(
|
||||
@@ -96,6 +132,53 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
updateAccessibilityViewport()
|
||||
}
|
||||
|
||||
public func configureDrawing(
|
||||
pageIndex: Int,
|
||||
document: RDPDFReaderDrawingDocument,
|
||||
documentChanged: @escaping (RDPDFReaderDrawingDocument) -> Void
|
||||
) {
|
||||
drawingCanvas.currentPage = pageIndex
|
||||
drawingCanvas.load(document: document)
|
||||
drawingCanvas.pathsChangedHandler = { [weak drawingCanvas] _ in
|
||||
guard let drawingCanvas else { return }
|
||||
documentChanged(drawingCanvas.drawingDocument())
|
||||
}
|
||||
drawingCanvas.strokeEventHandler = { [weak self] point, phase in
|
||||
guard let self else { return }
|
||||
self.drawingStrokeEventHandler?(self, self.convert(point, from: self.drawingCanvas), phase)
|
||||
}
|
||||
}
|
||||
|
||||
public func setDrawingTool(_ tool: RDPDFReaderDrawingTool, color: UIColor? = nil, lineWidth: CGFloat? = nil) {
|
||||
drawingCanvas.tool = tool
|
||||
if let color { drawingCanvas.strokeColor = color }
|
||||
if let lineWidth { drawingCanvas.lineWidth = lineWidth }
|
||||
}
|
||||
|
||||
public func undoDrawing() { drawingCanvas.undo() }
|
||||
public func redoDrawing() { drawingCanvas.redo() }
|
||||
public func clearDrawing() { drawingCanvas.clearAll() }
|
||||
public func drawingLayers() -> [RDPDFReaderDrawingLayer] { drawingCanvas.drawingLayers() }
|
||||
public func selectedDrawingLayerID() -> UUID? { drawingCanvas.selectedDrawingLayerID() }
|
||||
public func addDrawingLayer() { drawingCanvas.addLayer() }
|
||||
public func selectDrawingLayer(id: UUID) { drawingCanvas.selectLayer(id: id) }
|
||||
public func setDrawingLayerVisibility(id: UUID, isVisible: Bool) { drawingCanvas.setLayerVisibility(id: id, isVisible: isVisible) }
|
||||
public func deleteDrawingLayer(id: UUID) { drawingCanvas.deleteLayer(id: id) }
|
||||
|
||||
func containsDrawingPoint(_ point: CGPoint, from sourceView: UIView) -> Bool {
|
||||
drawingCanvas.bounds.contains(sourceView.convert(point, to: drawingCanvas))
|
||||
}
|
||||
|
||||
func continueDrawingStroke(at point: CGPoint, from sourceView: UIView, phase: RDPDFReaderDrawingStrokePhase) {
|
||||
let localPoint = sourceView.convert(point, to: drawingCanvas)
|
||||
switch phase {
|
||||
case .began: drawingCanvas.beginExternalStroke(at: localPoint)
|
||||
case .moved: drawingCanvas.updateExternalStroke(at: localPoint)
|
||||
case .ended: drawingCanvas.endExternalStroke(at: localPoint)
|
||||
case .cancelled: drawingCanvas.endExternalStroke(at: localPoint)
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用阅读主题。SDK 不依赖任何主题类型,宿主传入解析后的颜色。
|
||||
public func applyTheme(contentBackgroundColor: UIColor, surroundingBackgroundColor: UIColor) {
|
||||
backgroundColor = contentBackgroundColor
|
||||
@@ -132,6 +215,13 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
}
|
||||
zoomView.contentView.addSubview(textLayer)
|
||||
|
||||
drawingCanvas.frame = zoomView.contentView.bounds
|
||||
drawingCanvas.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
drawingCanvas.clipsToBounds = true
|
||||
drawingCanvas.isHidden = true
|
||||
drawingCanvas.isUserInteractionEnabled = false
|
||||
zoomView.contentView.addSubview(drawingCanvas)
|
||||
|
||||
contentAccessibilityView.frame = bounds
|
||||
contentAccessibilityView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentAccessibilityView.backgroundColor = .clear
|
||||
@@ -159,6 +249,7 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
super.layoutSubviews()
|
||||
zoomView.layoutIfNeeded()
|
||||
textLayer.frame = zoomView.contentView.bounds
|
||||
drawingCanvas.frame = zoomView.contentView.bounds
|
||||
}
|
||||
|
||||
// MARK: - 点击分发
|
||||
@@ -169,10 +260,6 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
return
|
||||
}
|
||||
let textLayerPoint = gesture.location(in: textLayer)
|
||||
if let annotation = textLayer.note(at: textLayerPoint) {
|
||||
delegate?.pageView(self, didOpenAnnotation: annotation)
|
||||
return
|
||||
}
|
||||
if let highlight = highlightAt(textLayerPoint) {
|
||||
showExistingHighlightMenu(for: highlight, at: gesture.location(in: self))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import Foundation
|
||||
|
||||
/// 标注文件无法安全读写时返回的错误。调用方应提示用户重试或导出原文件,SDK 不会
|
||||
/// 自动清空或覆盖已有文件。
|
||||
public enum RDPDFReaderAnnotationPersistenceError: LocalizedError {
|
||||
case unreadableFile(URL, Error)
|
||||
case invalidDocument(URL, Error)
|
||||
case unsupportedDocumentVersion(URL, Int)
|
||||
case duplicateAnnotationID(String)
|
||||
case encodingFailed(Error)
|
||||
case writeFailed(URL, Error)
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .unreadableFile:
|
||||
return "无法读取 PDF 标注文件。"
|
||||
case .invalidDocument:
|
||||
return "PDF 标注文件已损坏,原文件已保留。"
|
||||
case .unsupportedDocumentVersion:
|
||||
return "PDF 标注来自更新版本,当前版本无法安全保存。"
|
||||
case .duplicateAnnotationID:
|
||||
return "检测到重复的 PDF 标注标识。"
|
||||
case .encodingFailed:
|
||||
return "无法编码 PDF 标注。"
|
||||
case .writeFailed:
|
||||
return "无法保存 PDF 标注。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 阅读 SDK 的本地状态库:管理标注与笔迹文件,不依赖宿主数据库或文件管理器。
|
||||
public final class RDPDFReaderPersistenceStore: RDPDFReaderAnnotationPersisting {
|
||||
private struct AnnotationDocument: Codable {
|
||||
let version: Int
|
||||
let annotations: [RDPDFReaderAnnotation]
|
||||
}
|
||||
|
||||
private static let annotationDocumentVersion = 1
|
||||
/// 同一进程内的多个阅读器可能指向同一本书;将读改写串行化,避免最后一次写入
|
||||
/// 覆盖掉另一实例刚创建的标注。
|
||||
private static let annotationPersistenceLock = NSLock()
|
||||
private let rootURL: URL
|
||||
private let drawingsURL: URL
|
||||
private let highlightsURL: URL
|
||||
private let annotationsURL: URL
|
||||
|
||||
public init(rootURL: URL) {
|
||||
self.rootURL = rootURL
|
||||
drawingsURL = rootURL.appendingPathComponent("drawings", isDirectory: true)
|
||||
highlightsURL = rootURL.appendingPathComponent("highlights.json")
|
||||
annotationsURL = rootURL.appendingPathComponent("annotations.json")
|
||||
}
|
||||
|
||||
public func drawingPaths(pageNo: Int) -> [RDPDFReaderDrawingPath] {
|
||||
drawingDocument(pageNo: pageNo).paths
|
||||
}
|
||||
|
||||
public func drawingDocument(pageNo: Int) -> RDPDFReaderDrawingDocument {
|
||||
let url = drawingsURL.appendingPathComponent("\(pageNo).json")
|
||||
guard let data = try? Data(contentsOf: url), let document = try? JSONDecoder().decode(RDPDFReaderDrawingDocument.self, from: data) else {
|
||||
return .init(pageNo: pageNo, paths: [])
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
public func saveDrawingPaths(_ paths: [RDPDFReaderDrawingPath], pageNo: Int) {
|
||||
saveDrawingDocument(.init(pageNo: pageNo, paths: paths), pageNo: pageNo)
|
||||
}
|
||||
|
||||
public func saveDrawingDocument(_ document: RDPDFReaderDrawingDocument, pageNo: Int) {
|
||||
guard let data = try? JSONEncoder().encode(document) else { return }
|
||||
write(data, to: drawingsURL.appendingPathComponent("\(pageNo).json"))
|
||||
}
|
||||
|
||||
public func highlights(pageIndex: Int) -> [RDPDFReaderHighlight] { allHighlights().filter { $0.pageIndex == pageIndex } }
|
||||
public func allHighlights() -> [RDPDFReaderHighlight] {
|
||||
guard let data = try? Data(contentsOf: highlightsURL) else { return [] }
|
||||
return (try? JSONDecoder().decode([RDPDFReaderHighlight].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
public func addHighlight(pageIndex: Int, selectedText: String, color: String, normalizedRect: CGRect) -> RDPDFReaderHighlight {
|
||||
let item = RDPDFReaderHighlight(id: Int(Date().timeIntervalSince1970 * 1000), pageIndex: pageIndex, selectedText: selectedText, color: color, normalizedRect: normalizedRect)
|
||||
var items = allHighlights()
|
||||
items.append(item)
|
||||
saveHighlights(items)
|
||||
return item
|
||||
}
|
||||
|
||||
public func deleteHighlight(id: Int) { saveHighlights(allHighlights().filter { $0.id != id }) }
|
||||
|
||||
/// 返回当前书籍的全部高亮/注释记录。此兼容方法在文件不可读时返回空数组;
|
||||
/// 新代码若需要区分“没有标注”和“文件不可读”,请使用 `loadAnnotations()`。
|
||||
public func allAnnotations() -> [RDPDFReaderAnnotation] {
|
||||
(try? loadAnnotations()) ?? []
|
||||
}
|
||||
|
||||
/// 返回指定页面的标注,保持创建时的顺序。
|
||||
public func annotations(pageIndex: Int) -> [RDPDFReaderAnnotation] {
|
||||
allAnnotations().filter { $0.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
/// 读取全部标注。文件不存在表示空书;读取、版本或解码失败会抛错,且不会把原文件
|
||||
/// 当作空数组覆盖。
|
||||
public func loadAnnotations() throws -> [RDPDFReaderAnnotation] {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
return try loadAnnotationsLocked()
|
||||
}
|
||||
|
||||
/// `loadAnnotations()` 的按页便捷版本。
|
||||
public func loadAnnotations(pageIndex: Int) throws -> [RDPDFReaderAnnotation] {
|
||||
try loadAnnotations().filter { $0.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
/// 添加一条文字高亮、区域高亮或带笔记的标注。
|
||||
@discardableResult
|
||||
public func addAnnotation(
|
||||
pageIndex: Int,
|
||||
selectedText: String? = nil,
|
||||
normalizedRects: [CGRect],
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil,
|
||||
source: RDPDFReaderAnnotationSource = .region
|
||||
) throws -> RDPDFReaderAnnotation {
|
||||
let annotation = RDPDFReaderAnnotation(
|
||||
pageIndex: pageIndex,
|
||||
selectedText: selectedText,
|
||||
normalizedRects: normalizedRects,
|
||||
color: color,
|
||||
note: note,
|
||||
source: source
|
||||
)
|
||||
try addAnnotation(annotation)
|
||||
return annotation
|
||||
}
|
||||
|
||||
/// 添加已构造的标注,便于宿主导入或恢复已有标注。
|
||||
@discardableResult
|
||||
public func addAnnotation(_ annotation: RDPDFReaderAnnotation) throws -> RDPDFReaderAnnotation {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
var items = try loadAnnotationsLocked()
|
||||
guard !items.contains(where: { $0.id == annotation.id }) else {
|
||||
throw RDPDFReaderAnnotationPersistenceError.duplicateAnnotationID(annotation.id)
|
||||
}
|
||||
items.append(annotation)
|
||||
try saveAnnotationsLocked(items)
|
||||
return annotation
|
||||
}
|
||||
|
||||
/// 用相同 id 的新值更新标注。创建时间会被保留,更新时间由 SDK 统一写入。
|
||||
@discardableResult
|
||||
public func updateAnnotation(_ annotation: RDPDFReaderAnnotation) throws -> RDPDFReaderAnnotation? {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
var items = try loadAnnotationsLocked()
|
||||
guard let index = items.firstIndex(where: { $0.id == annotation.id }) else { return nil }
|
||||
|
||||
let existing = items[index]
|
||||
let updated = RDPDFReaderAnnotation(
|
||||
id: annotation.id,
|
||||
pageIndex: annotation.pageIndex,
|
||||
selectedText: annotation.selectedText,
|
||||
normalizedRects: annotation.normalizedRects,
|
||||
color: annotation.color,
|
||||
note: annotation.note,
|
||||
source: annotation.source,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
items[index] = updated
|
||||
try saveAnnotationsLocked(items)
|
||||
return updated
|
||||
}
|
||||
|
||||
/// 删除指定标注;返回是否确实删除了记录。
|
||||
@discardableResult
|
||||
public func deleteAnnotation(id: String) throws -> Bool {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
let items = try loadAnnotationsLocked()
|
||||
let remaining = items.filter { $0.id != id }
|
||||
guard remaining.count != items.count else { return false }
|
||||
try saveAnnotationsLocked(remaining)
|
||||
return true
|
||||
}
|
||||
|
||||
/// 清空全部高亮/注释记录。
|
||||
public func removeAllAnnotations() throws {
|
||||
Self.annotationPersistenceLock.lock()
|
||||
defer { Self.annotationPersistenceLock.unlock() }
|
||||
// 先确认旧文件仍可读,避免“清空”意外覆盖损坏文件。
|
||||
_ = try loadAnnotationsLocked()
|
||||
try saveAnnotationsLocked([])
|
||||
}
|
||||
|
||||
private func saveHighlights(_ highlights: [RDPDFReaderHighlight]) {
|
||||
guard let data = try? JSONEncoder().encode(highlights) else { return }
|
||||
write(data, to: highlightsURL)
|
||||
}
|
||||
|
||||
private func loadAnnotationsLocked() throws -> [RDPDFReaderAnnotation] {
|
||||
guard FileManager.default.fileExists(atPath: annotationsURL.path) else { return [] }
|
||||
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: annotationsURL)
|
||||
} catch {
|
||||
throw RDPDFReaderAnnotationPersistenceError.unreadableFile(annotationsURL, error)
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
let document = try decoder.decode(AnnotationDocument.self, from: data)
|
||||
guard document.version <= Self.annotationDocumentVersion else {
|
||||
throw RDPDFReaderAnnotationPersistenceError.unsupportedDocumentVersion(annotationsURL, document.version)
|
||||
}
|
||||
return document.annotations
|
||||
} catch let error as RDPDFReaderAnnotationPersistenceError {
|
||||
throw error
|
||||
} catch {
|
||||
// 兼容首版实现写出的裸数组;读到后会在下一次成功修改时升级为版本化文档。
|
||||
do {
|
||||
return try decoder.decode([RDPDFReaderAnnotation].self, from: data)
|
||||
} catch {
|
||||
throw RDPDFReaderAnnotationPersistenceError.invalidDocument(annotationsURL, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAnnotationsLocked(_ annotations: [RDPDFReaderAnnotation]) throws {
|
||||
let document = AnnotationDocument(version: Self.annotationDocumentVersion, annotations: annotations)
|
||||
let data: Data
|
||||
do {
|
||||
data = try JSONEncoder().encode(document)
|
||||
} catch {
|
||||
throw RDPDFReaderAnnotationPersistenceError.encodingFailed(error)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: annotationsURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: annotationsURL, options: .atomic)
|
||||
} catch {
|
||||
throw RDPDFReaderAnnotationPersistenceError.writeFailed(annotationsURL, error)
|
||||
}
|
||||
}
|
||||
private func write(_ data: Data, to url: URL) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: .atomic)
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 阅读设置面板:亮度、翻页方式、主题。
|
||||
/// 主题列表由宿主传入 `RDPDFReaderThemeOption`,选中结果原样回传,
|
||||
@@ -19,17 +20,20 @@ public final class RDPDFReaderSettingsPanelViewController: UIViewController {
|
||||
private let themes: [RDPDFReaderThemeOption]
|
||||
private var currentDisplayType: RDPDFReaderView.DisplayType
|
||||
private var currentTheme: RDPDFReaderThemeOption
|
||||
private let allowsDisplayTypeSelection: Bool
|
||||
|
||||
public init(
|
||||
displayType: RDPDFReaderView.DisplayType,
|
||||
brightness: CGFloat,
|
||||
themes: [RDPDFReaderThemeOption],
|
||||
selectedThemeIdentifier: Int
|
||||
selectedThemeIdentifier: Int,
|
||||
allowsDisplayTypeSelection: Bool = true
|
||||
) {
|
||||
precondition(!themes.isEmpty, "至少需要一个主题选项")
|
||||
self.themes = themes
|
||||
currentDisplayType = displayType
|
||||
currentTheme = themes.first { $0.identifier == selectedThemeIdentifier } ?? themes[0]
|
||||
self.allowsDisplayTypeSelection = allowsDisplayTypeSelection
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
brightnessSlider.value = Float(brightness)
|
||||
}
|
||||
@@ -54,12 +58,7 @@ public final class RDPDFReaderSettingsPanelViewController: UIViewController {
|
||||
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
|
||||
displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
|
||||
|
||||
[handleView, closeButton, scrollView].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview($0)
|
||||
}
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
[handleView, closeButton, scrollView].forEach(view.addSubview)
|
||||
scrollView.addSubview(contentView)
|
||||
|
||||
handleView.layer.cornerRadius = 3
|
||||
@@ -71,6 +70,8 @@ public final class RDPDFReaderSettingsPanelViewController: UIViewController {
|
||||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
||||
|
||||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
|
||||
displayTypeControl.isEnabled = allowsDisplayTypeSelection
|
||||
displayTypeControl.accessibilityHint = allowsDisplayTypeSelection ? nil : "手机横屏时固定为上下滚动"
|
||||
|
||||
themeStack.axis = .horizontal
|
||||
themeStack.distribution = .fillEqually
|
||||
@@ -87,69 +88,70 @@ public final class RDPDFReaderSettingsPanelViewController: UIViewController {
|
||||
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
||||
themeButtons.append(button)
|
||||
themeStack.addArrangedSubview(button)
|
||||
button.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
||||
button.snp.makeConstraints { $0.height.equalTo(30) }
|
||||
}
|
||||
|
||||
let brightnessLabel = makeLabel("亮度")
|
||||
let displayTypeLabel = makeLabel("翻页方式")
|
||||
let displayTypeLabel = makeLabel(allowsDisplayTypeSelection ? "翻页方式" : "翻页方式(横屏已固定为竖滑)")
|
||||
let themeLabel = makeLabel("主题")
|
||||
let dividerOne = makeDivider()
|
||||
let dividerTwo = makeDivider()
|
||||
|
||||
[brightnessLabel, displayTypeLabel, themeLabel, brightnessSlider, displayTypeControl, themeStack, dividerOne, dividerTwo].forEach {
|
||||
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview($0)
|
||||
[brightnessLabel, displayTypeLabel, themeLabel, brightnessSlider, displayTypeControl, themeStack, dividerOne, dividerTwo].forEach(contentView.addSubview)
|
||||
|
||||
handleView.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(10)
|
||||
make.centerX.equalToSuperview()
|
||||
make.size.equalTo(CGSize(width: 42, height: 5))
|
||||
}
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalTo(handleView.snp.centerY).offset(20)
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.equalTo(handleView.snp.bottom).offset(14)
|
||||
make.horizontalEdges.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
contentView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(scrollView.contentLayoutGuide)
|
||||
make.width.equalTo(scrollView.frameLayoutGuide)
|
||||
}
|
||||
brightnessLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(20)
|
||||
make.top.equalToSuperview().offset(8)
|
||||
}
|
||||
brightnessSlider.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview().inset(20)
|
||||
make.top.equalTo(brightnessLabel.snp.bottom).offset(8)
|
||||
}
|
||||
dividerOne.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview().inset(20)
|
||||
make.top.equalTo(brightnessSlider.snp.bottom).offset(14)
|
||||
}
|
||||
displayTypeLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(20)
|
||||
make.top.equalTo(dividerOne.snp.bottom).offset(14)
|
||||
}
|
||||
displayTypeControl.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview().inset(20)
|
||||
make.top.equalTo(displayTypeLabel.snp.bottom).offset(8)
|
||||
make.height.equalTo(32)
|
||||
}
|
||||
dividerTwo.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview().inset(20)
|
||||
make.top.equalTo(displayTypeControl.snp.bottom).offset(14)
|
||||
}
|
||||
themeLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(20)
|
||||
make.top.equalTo(dividerTwo.snp.bottom).offset(14)
|
||||
}
|
||||
themeStack.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview().inset(24)
|
||||
make.top.equalTo(themeLabel.snp.bottom).offset(12)
|
||||
make.bottom.equalToSuperview().inset(20)
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
handleView.topAnchor.constraint(equalTo: view.topAnchor, constant: 10),
|
||||
handleView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
handleView.widthAnchor.constraint(equalToConstant: 42),
|
||||
handleView.heightAnchor.constraint(equalToConstant: 5),
|
||||
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||
closeButton.centerYAnchor.constraint(equalTo: handleView.centerYAnchor, constant: 20),
|
||||
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||
closeButton.heightAnchor.constraint(equalToConstant: 32),
|
||||
|
||||
scrollView.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 14),
|
||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
||||
|
||||
contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||
contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
|
||||
contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
|
||||
contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
|
||||
contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
|
||||
|
||||
brightnessLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
brightnessLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
|
||||
brightnessSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
brightnessSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
|
||||
brightnessSlider.topAnchor.constraint(equalTo: brightnessLabel.bottomAnchor, constant: 8),
|
||||
|
||||
dividerOne.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
dividerOne.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
|
||||
dividerOne.topAnchor.constraint(equalTo: brightnessSlider.bottomAnchor, constant: 14),
|
||||
|
||||
displayTypeLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
displayTypeLabel.topAnchor.constraint(equalTo: dividerOne.bottomAnchor, constant: 14),
|
||||
displayTypeControl.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
displayTypeControl.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
|
||||
displayTypeControl.topAnchor.constraint(equalTo: displayTypeLabel.bottomAnchor, constant: 8),
|
||||
displayTypeControl.heightAnchor.constraint(equalToConstant: 32),
|
||||
|
||||
dividerTwo.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
dividerTwo.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
|
||||
dividerTwo.topAnchor.constraint(equalTo: displayTypeControl.bottomAnchor, constant: 14),
|
||||
|
||||
themeLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
|
||||
themeLabel.topAnchor.constraint(equalTo: dividerTwo.bottomAnchor, constant: 14),
|
||||
themeStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24),
|
||||
themeStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24),
|
||||
themeStack.topAnchor.constraint(equalTo: themeLabel.bottomAnchor, constant: 12),
|
||||
themeStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20)
|
||||
])
|
||||
}
|
||||
|
||||
private func makeLabel(_ text: String) -> UILabel {
|
||||
@@ -161,7 +163,7 @@ public final class RDPDFReaderSettingsPanelViewController: UIViewController {
|
||||
|
||||
private func makeDivider() -> UIView {
|
||||
let divider = UIView()
|
||||
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||
divider.snp.makeConstraints { $0.height.equalTo(1 / UIScreen.main.scale) }
|
||||
return divider
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// SDK 阅读设置面板。只表达 UI 选择,不直接修改系统亮度或宿主偏好。
|
||||
public final class RDPDFReaderSettingsView: UIView {
|
||||
public var brightnessChangedHandler: ((Float) -> Void)?
|
||||
public var nightModeChangedHandler: ((Bool) -> Void)?
|
||||
public var displayModeChangedHandler: ((RDPDFReaderDisplayMode) -> Void)?
|
||||
public var drawingModeHandler: (() -> Void)?
|
||||
|
||||
private let brightnessSlider = UISlider()
|
||||
private let nightModeSwitch = UISwitch()
|
||||
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
setupViews()
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public func configure(brightness: Float, nightMode: Bool, displayMode: RDPDFReaderDisplayMode) {
|
||||
brightnessSlider.value = brightness
|
||||
nightModeSwitch.isOn = nightMode
|
||||
displayTypeControl.selectedSegmentIndex = displayMode.rawValue
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
let brightnessLabel = makeLabel("亮度")
|
||||
let nightModeLabel = makeLabel("夜间模式")
|
||||
let displayTypeLabel = makeLabel("翻页方式")
|
||||
let drawingButton = UIButton(type: .system)
|
||||
drawingButton.setTitle("进入画笔模式", for: .normal)
|
||||
drawingButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
|
||||
drawingButton.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.10)
|
||||
drawingButton.layer.cornerRadius = 8
|
||||
drawingButton.addTarget(self, action: #selector(drawingTapped), for: .touchUpInside)
|
||||
|
||||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged), for: .valueChanged)
|
||||
nightModeSwitch.addTarget(self, action: #selector(nightModeChanged), for: .valueChanged)
|
||||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged), for: .valueChanged)
|
||||
|
||||
[brightnessLabel, brightnessSlider, nightModeLabel, nightModeSwitch, displayTypeLabel, displayTypeControl, drawingButton].forEach(addSubview)
|
||||
brightnessLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(20)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
}
|
||||
brightnessSlider.snp.makeConstraints { make in
|
||||
make.top.equalTo(brightnessLabel.snp.bottom).offset(8)
|
||||
make.horizontalEdges.equalToSuperview().inset(16)
|
||||
}
|
||||
nightModeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(brightnessSlider.snp.bottom).offset(20)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
}
|
||||
nightModeSwitch.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(nightModeLabel)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
displayTypeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nightModeLabel.snp.bottom).offset(20)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
}
|
||||
displayTypeControl.snp.makeConstraints { make in
|
||||
make.top.equalTo(displayTypeLabel.snp.bottom).offset(8)
|
||||
make.horizontalEdges.equalToSuperview().inset(16)
|
||||
}
|
||||
drawingButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(displayTypeControl.snp.bottom).offset(16)
|
||||
make.horizontalEdges.equalToSuperview().inset(16)
|
||||
make.height.equalTo(44)
|
||||
make.bottom.equalToSuperview().inset(20)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeLabel(_ text: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = text
|
||||
label.font = .systemFont(ofSize: 15)
|
||||
return label
|
||||
}
|
||||
|
||||
@objc private func brightnessChanged() { brightnessChangedHandler?(brightnessSlider.value) }
|
||||
@objc private func nightModeChanged() { nightModeChangedHandler?(nightModeSwitch.isOn) }
|
||||
@objc private func displayTypeChanged() {
|
||||
displayModeChangedHandler?(RDPDFReaderDisplayMode(rawValue: displayTypeControl.selectedSegmentIndex) ?? .pageCurl)
|
||||
}
|
||||
@objc private func drawingTapped() { drawingModeHandler?() }
|
||||
}
|
||||
@@ -31,6 +31,18 @@ public struct RDPDFReaderThemeOption: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public extension RDPDFReaderThemeOption {
|
||||
/// PDF SDK 自带的中立主题预设,不依赖 EPUB 模块。
|
||||
static let defaultPresets: [Self] = [
|
||||
.init(identifier: 0, title: "浅色", contentBackgroundColor: .white, contentTextColor: .black, toolBackgroundColor: .white, toolControlTextColor: .black, toolLineColor: UIColor(white: 0.85, alpha: 1)),
|
||||
.init(identifier: 1, title: "米黄", contentBackgroundColor: UIColor(red: 0.98, green: 0.95, blue: 0.84, alpha: 1), contentTextColor: .black, toolBackgroundColor: UIColor(red: 0.98, green: 0.95, blue: 0.84, alpha: 1), toolControlTextColor: .black, toolLineColor: UIColor(white: 0.75, alpha: 1)),
|
||||
.init(identifier: 2, title: "青绿", contentBackgroundColor: UIColor(red: 0.86, green: 0.94, blue: 0.89, alpha: 1), contentTextColor: .black, toolBackgroundColor: UIColor(red: 0.86, green: 0.94, blue: 0.89, alpha: 1), toolControlTextColor: .black, toolLineColor: UIColor(white: 0.70, alpha: 1)),
|
||||
.init(identifier: 3, title: "粉色", contentBackgroundColor: UIColor(red: 0.98, green: 0.90, blue: 0.91, alpha: 1), contentTextColor: .black, toolBackgroundColor: UIColor(red: 0.98, green: 0.90, blue: 0.91, alpha: 1), toolControlTextColor: .black, toolLineColor: UIColor(white: 0.72, alpha: 1)),
|
||||
.init(identifier: 4, title: "蓝灰", contentBackgroundColor: UIColor(red: 0.88, green: 0.92, blue: 0.96, alpha: 1), contentTextColor: .black, toolBackgroundColor: UIColor(red: 0.88, green: 0.92, blue: 0.96, alpha: 1), toolControlTextColor: .black, toolLineColor: UIColor(white: 0.70, alpha: 1)),
|
||||
.init(identifier: 5, title: "夜间", contentBackgroundColor: UIColor(white: 0.12, alpha: 1), contentTextColor: .white, toolBackgroundColor: UIColor(white: 0.12, alpha: 1), toolControlTextColor: .white, toolLineColor: UIColor(white: 0.3, alpha: 1))
|
||||
]
|
||||
}
|
||||
|
||||
public extension UIColor {
|
||||
/// 解析 "#RRGGBB" 形式的颜色。标注模型的 `color` 字段使用此格式。
|
||||
convenience init(rdPDFHexString: String) {
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 成品 PDF 阅读控制器。宿主提供已解析的页面和可选存储;阅读交互、OCR、标注及面板
|
||||
/// 由 SDK 统一编排,因此 SDK 不需要接触 PDFKit、数据库或应用路由。
|
||||
public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataSource, RDPDFReaderDelegate, RDPDFReaderPageViewDelegate {
|
||||
|
||||
public struct Configuration {
|
||||
public var displayType: RDPDFReaderView.DisplayType = .pageCurl
|
||||
/// 默认与 EPUB 成品控制器一致:横屏仿真/横滑使用双页,竖滑保持单页。
|
||||
public var landscapeDualPageEnabled = true
|
||||
public var themes: [RDPDFReaderThemeOption] = RDPDFReaderThemeOption.defaultPresets
|
||||
public var initialThemeIdentifier: Int = 0
|
||||
public var enablesOCR = true
|
||||
public var recognitionLanguages: [String] = []
|
||||
/// 未提供文字且 OCR 关闭时,页面仍可使用区域标注。
|
||||
public var missingTextSource: RDPDFReaderAnnotationSource = .region
|
||||
/// 页面图片归宿主所有;宿主可在此进行缓存、反色或其它主题渲染。
|
||||
public var pageImageTransform: ((UIImage, RDPDFReaderThemeOption) -> UIImage)?
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
public weak var delegate: RDPDFReaderViewControllerDelegate?
|
||||
public let pageProvider: RDPDFReaderPageProvider
|
||||
public weak var persistence: RDPDFReaderPersistence?
|
||||
public let annotationPersistence: RDPDFReaderAnnotationPersisting?
|
||||
public private(set) var configuration: Configuration
|
||||
|
||||
private let readerView = RDPDFReaderView()
|
||||
private let recognizer: RDPDFReaderImageTextRecognizer
|
||||
private var book: RDPDFReaderBookDescriptor
|
||||
private var currentTheme: RDPDFReaderThemeOption
|
||||
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
|
||||
private var ocrRuns: [Int: [RDPDFReaderTextRun]] = [:]
|
||||
private var recognizingPages = Set<Int>()
|
||||
private var bookmarks = Set<Int>()
|
||||
private weak var topToolbar: RDPDFReaderKitTopToolView?
|
||||
private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
|
||||
private let drawingToolbar = RDPDFReaderDrawingToolbar()
|
||||
private var isDrawingMode = false
|
||||
private var currentDrawingTool: RDPDFReaderDrawingTool = .pen
|
||||
private var currentDrawingColor = UIColor.black
|
||||
private var currentDrawingLineWidth: CGFloat = 4
|
||||
private weak var continuedDrawingPage: RDPDFReaderPageView?
|
||||
/// 图层面板始终针对最后一次绘制的实际页;双页场景下不会误操作另一页。
|
||||
private weak var activeDrawingPage: RDPDFReaderPageView?
|
||||
/// 用户在可切换状态下选择的阅读方式;手机横屏仅临时覆盖为竖滑。
|
||||
private var preferredDisplayType: RDPDFReaderView.DisplayType
|
||||
/// 旋转动画期间不能依赖尚未更新的 view.bounds,提前锁定目标方向以避免先建出双页。
|
||||
private var phoneLandscapeTransitionOverride: Bool?
|
||||
|
||||
public init(
|
||||
pageProvider: RDPDFReaderPageProvider,
|
||||
persistence: RDPDFReaderPersistence? = nil,
|
||||
annotationPersistence: RDPDFReaderAnnotationPersisting? = nil,
|
||||
configuration: Configuration = .init()
|
||||
) {
|
||||
precondition(!configuration.themes.isEmpty, "至少需要一个 PDF 阅读主题")
|
||||
self.pageProvider = pageProvider
|
||||
self.persistence = persistence
|
||||
self.annotationPersistence = annotationPersistence
|
||||
self.configuration = configuration
|
||||
book = pageProvider.readerBookDescriptor()
|
||||
currentTheme = configuration.themes.first { $0.identifier == configuration.initialThemeIdentifier } ?? configuration.themes[0]
|
||||
preferredDisplayType = configuration.displayType
|
||||
recognizer = RDPDFReaderImageTextRecognizer(recognitionLanguages: configuration.recognitionLanguages)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = book.title
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = currentTheme.contentBackgroundColor
|
||||
readerView.backgroundColor = currentTheme.contentBackgroundColor
|
||||
readerView.frame = view.bounds
|
||||
readerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
readerView.dataSource = self
|
||||
readerView.delegate = self
|
||||
readerView.currentDisplayType = configuration.displayType
|
||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
||||
// 手机横屏竖滑的 cell 高度 = 安全区内可用宽度按页面纵横比换算的纸张高度。
|
||||
readerView.verticalScrollWidthFitPageHeightProvider = { [weak self] pageNum, width in
|
||||
guard let self,
|
||||
let imageSize = self.pageDescriptors[pageNum]?.image?.size,
|
||||
imageSize.width > 0, imageSize.height > 0 else { return nil }
|
||||
let insets = self.view.safeAreaInsets
|
||||
let availableWidth = max(1, width - insets.left - insets.right)
|
||||
return availableWidth * imageSize.height / imageSize.width
|
||||
}
|
||||
view.addSubview(readerView)
|
||||
readerView.reloadData()
|
||||
applyDisplayTypeForCurrentInterface()
|
||||
if let page = persistence?.restoreReadingPage(for: book.identifier), page >= 0, page < book.totalPages {
|
||||
readerView.transitionToPage(pageNum: page, animated: false)
|
||||
}
|
||||
bookmarks = Set(persistence?.loadBookmarks(for: book.identifier).map(\.pageIndex) ?? [])
|
||||
}
|
||||
|
||||
public func switchDisplayType(_ type: RDPDFReaderView.DisplayType) {
|
||||
preferredDisplayType = type
|
||||
applyDisplayTypeForCurrentInterface()
|
||||
}
|
||||
|
||||
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
let forcePhoneLandscape = traitCollection.userInterfaceIdiom == .phone && size.width > size.height
|
||||
phoneLandscapeTransitionOverride = forcePhoneLandscape
|
||||
coordinator.animate(alongsideTransition: { [weak self] _ in
|
||||
// 在 ReaderView 根据新尺寸重排前完成模式切换,杜绝横屏先闪现双页。
|
||||
self?.applyDisplayTypeForCurrentInterface()
|
||||
}) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.phoneLandscapeTransitionOverride = nil
|
||||
self.applyDisplayTypeForCurrentInterface()
|
||||
}
|
||||
}
|
||||
|
||||
public func goToPage(_ pageIndex: Int, animated: Bool = false) {
|
||||
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
|
||||
readerView.transitionToPage(pageNum: pageIndex, animated: animated)
|
||||
}
|
||||
|
||||
/// 首版画笔模式入口。横屏双页下左右内容页各自承载画布,笔迹严格裁剪在所属页内。
|
||||
public func setDrawingMode(_ enabled: Bool) {
|
||||
guard isDrawingMode != enabled else { return }
|
||||
isDrawingMode = enabled
|
||||
readerView.setPagingEnabled(!enabled)
|
||||
if enabled {
|
||||
installDrawingToolbar()
|
||||
} else {
|
||||
drawingToolbar.removeFromSuperview()
|
||||
}
|
||||
configureVisibleDrawingPages()
|
||||
}
|
||||
|
||||
public func pageCountOfReaderView(readerView: RDPDFReaderView) -> Int { book.totalPages }
|
||||
|
||||
public func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView {
|
||||
let page = RDPDFReaderPageView()
|
||||
page.delegate = self
|
||||
configure(page, at: pageNum)
|
||||
return page
|
||||
}
|
||||
|
||||
public func topToolView(readerView: RDPDFReaderView) -> UIView? {
|
||||
let toolbar = RDPDFReaderKitTopToolView()
|
||||
toolbar.onBack = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.delegate?.pdfReaderViewControllerDidRequestClose(self)
|
||||
}
|
||||
toolbar.onToggleBookmark = { [weak self] in self?.toggleBookmark() }
|
||||
topToolbar = toolbar
|
||||
applyChromeTheme()
|
||||
return toolbar
|
||||
}
|
||||
|
||||
public func bottomToolView(readerView: RDPDFReaderView) -> UIView? {
|
||||
let toolbar = RDPDFReaderKitBottomToolView()
|
||||
toolbar.onShowTableOfContents = { [weak self] in self?.showNavigation() }
|
||||
toolbar.onShowAnnotations = { [weak self] in self?.showAnnotations() }
|
||||
toolbar.onStartDrawing = { [weak self] in self?.setDrawingMode(true) }
|
||||
toolbar.onShowSettings = { [weak self] in self?.showSettings() }
|
||||
bottomToolbar = toolbar
|
||||
applyChromeTheme()
|
||||
return toolbar
|
||||
}
|
||||
|
||||
public func pageNum(readerView: RDPDFReaderView, pageNum: Int) {
|
||||
guard pageNum >= 0 else { return }
|
||||
title = "PDF · \(pageNum + 1) / \(book.totalPages)"
|
||||
topToolbar?.setTitle(title ?? book.title)
|
||||
topToolbar?.setBookmarkSelected(bookmarks.contains(pageNum))
|
||||
persistence?.saveReadingPage(pageNum, for: book.identifier)
|
||||
delegate?.pdfReaderViewController(self, didChangePage: pageNum)
|
||||
}
|
||||
|
||||
private func configure(_ page: RDPDFReaderPageView, at index: Int) {
|
||||
let descriptor = pageDescriptors[index]
|
||||
page.image = descriptor.flatMap { renderedImage($0.image) }
|
||||
page.applyTheme(contentBackgroundColor: currentTheme.contentBackgroundColor, surroundingBackgroundColor: pageSurroundingColor)
|
||||
page.setPhoneLandscapeWidthFitting(isPhoneLandscape, safeAreaInsets: view.safeAreaInsets)
|
||||
let runs = descriptor?.textRuns ?? ocrRuns[index] ?? []
|
||||
let source: RDPDFReaderAnnotationSource = descriptor?.textRuns != nil ? .text : (configuration.enablesOCR ? .ocr : configuration.missingTextSource)
|
||||
page.configureTextLayer(pageIndex: index, textRuns: runs, textSource: source, annotations: annotations(for: index))
|
||||
page.isDrawingMode = isDrawingMode
|
||||
page.configureDrawing(
|
||||
pageIndex: index,
|
||||
document: (annotationPersistence as? RDPDFReaderPersistenceStore)?.drawingDocument(pageNo: index) ?? .init(pageNo: index, paths: []),
|
||||
documentChanged: { [weak self] document in
|
||||
(self?.annotationPersistence as? RDPDFReaderPersistenceStore)?.saveDrawingDocument(document, pageNo: index)
|
||||
}
|
||||
)
|
||||
page.setDrawingTool(currentDrawingTool, color: currentDrawingColor, lineWidth: currentDrawingLineWidth)
|
||||
page.drawingStrokeEventHandler = { [weak self] source, point, phase in
|
||||
if phase == .began { self?.activeDrawingPage = source }
|
||||
self?.routeDrawingStroke(from: source, point: point, phase: phase)
|
||||
}
|
||||
guard descriptor == nil else { startOCRIfNeeded(index); return }
|
||||
pageProvider.readerPage(at: index) { [weak self, weak page] descriptor in
|
||||
guard let self, descriptor.index == index else { return }
|
||||
self.pageDescriptors[index] = descriptor
|
||||
// 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。
|
||||
self.readerView.invalidateWidthFitLayoutIfNeeded()
|
||||
if let page { self.configure(page, at: index) }
|
||||
}
|
||||
}
|
||||
|
||||
private func renderedImage(_ image: UIImage?) -> UIImage? {
|
||||
guard let image else { return nil }
|
||||
return configuration.pageImageTransform?(image, currentTheme) ?? image
|
||||
}
|
||||
|
||||
private func startOCRIfNeeded(_ index: Int) {
|
||||
guard configuration.enablesOCR, pageDescriptors[index]?.textRuns == nil, ocrRuns[index] == nil,
|
||||
!recognizingPages.contains(index), let image = pageDescriptors[index]?.image else { return }
|
||||
recognizingPages.insert(index)
|
||||
recognizer.recognizeTextRuns(in: image) { [weak self] runs in
|
||||
guard let self else { return }
|
||||
self.recognizingPages.remove(index)
|
||||
self.ocrRuns[index] = runs
|
||||
self.refreshVisiblePage(index)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshVisiblePage(_ index: Int) {
|
||||
guard let page = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView else { return }
|
||||
configure(page, at: index)
|
||||
}
|
||||
|
||||
private func annotations(for index: Int) -> [RDPDFReaderAnnotation] {
|
||||
do { return try annotationPersistence?.loadAnnotations().filter { $0.pageIndex == index } ?? [] }
|
||||
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error); return [] }
|
||||
}
|
||||
|
||||
private func toggleBookmark() {
|
||||
let page = readerView.currentPage
|
||||
guard page >= 0 else { return }
|
||||
let marked = !bookmarks.contains(page)
|
||||
if marked { bookmarks.insert(page) } else { bookmarks.remove(page) }
|
||||
persistence?.setBookmark(marked, pageIndex: page, for: book.identifier)
|
||||
topToolbar?.setBookmarkSelected(marked)
|
||||
}
|
||||
|
||||
private func showNavigation() {
|
||||
let outline = (pageProvider as? RDPDFReaderOutlineProviding)?.readerOutlineItems() ?? (0..<book.totalPages).map { .init(title: "第 \($0 + 1) 页", pageIndex: $0) }
|
||||
let marks = bookmarks.sorted().map { RDPDFReaderBookmark(pageIndex: $0, title: "第 \($0 + 1) 页") }
|
||||
let panel = RDPDFReaderNavigationPanelViewController(outlineItems: outline, bookmarks: marks, totalPages: book.totalPages, thumbnailProvider: { [weak self] index, size, completion in self?.pageProvider.readerThumbnail(at: index, targetSize: size, completion: completion) }, onSelectPage: { [weak self] page in self?.readerView.transitionToPage(pageNum: page, animated: false); self?.readerView.hideToolViewIfNeeded() })
|
||||
RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .navigation)
|
||||
}
|
||||
|
||||
private func showSettings() {
|
||||
let panel = RDPDFReaderSettingsPanelViewController(
|
||||
displayType: readerView.currentDisplayType,
|
||||
brightness: UIScreen.main.brightness,
|
||||
themes: configuration.themes,
|
||||
selectedThemeIdentifier: currentTheme.identifier,
|
||||
allowsDisplayTypeSelection: !isPhoneLandscape
|
||||
)
|
||||
panel.onBrightnessChange = { UIScreen.main.brightness = $0 }
|
||||
panel.onDisplayTypeChange = { [weak self] type in self?.switchDisplayType(type) }
|
||||
panel.onThemeChange = { [weak self] theme in self?.apply(theme) }
|
||||
RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .settings)
|
||||
}
|
||||
|
||||
private func installDrawingToolbar() {
|
||||
guard drawingToolbar.superview == nil else { return }
|
||||
view.addSubview(drawingToolbar)
|
||||
drawingToolbar.snp.makeConstraints { make in
|
||||
make.horizontalEdges.equalToSuperview()
|
||||
// 工具条背景延伸到屏幕底部;内部控件再自行避开 Home Indicator。
|
||||
make.bottom.equalToSuperview()
|
||||
}
|
||||
drawingToolbar.toolChangedHandler = { [weak self] tool in self?.applyDrawingTool(tool) }
|
||||
drawingToolbar.colorChangedHandler = { [weak self] color in
|
||||
guard let self else { return }
|
||||
self.currentDrawingColor = color
|
||||
self.applyDrawingTool(self.currentDrawingTool, color: color)
|
||||
}
|
||||
drawingToolbar.lineWidthChangedHandler = { [weak self] width in
|
||||
guard let self else { return }
|
||||
self.currentDrawingLineWidth = width
|
||||
self.visibleDrawingPages.forEach { $0.setDrawingTool(self.currentDrawingTool, lineWidth: width) }
|
||||
}
|
||||
drawingToolbar.undoHandler = { [weak self] in self?.visibleDrawingPages.forEach { $0.undoDrawing() } }
|
||||
drawingToolbar.redoHandler = { [weak self] in self?.visibleDrawingPages.forEach { $0.redoDrawing() } }
|
||||
drawingToolbar.clearHandler = { [weak self] in self?.visibleDrawingPages.forEach { $0.clearDrawing() } }
|
||||
drawingToolbar.layersHandler = { [weak self] in self?.showDrawingLayers() }
|
||||
drawingToolbar.doneHandler = { [weak self] in self?.setDrawingMode(false) }
|
||||
}
|
||||
|
||||
private var visibleDrawingPages: [RDPDFReaderPageView] {
|
||||
readerView.visiblePageContentViews().compactMap { $0 as? RDPDFReaderPageView }
|
||||
}
|
||||
|
||||
private func configureVisibleDrawingPages() {
|
||||
visibleDrawingPages.forEach { page in page.isDrawingMode = isDrawingMode }
|
||||
}
|
||||
|
||||
private func applyDrawingTool(_ tool: RDPDFReaderDrawingTool, color: UIColor? = nil) {
|
||||
currentDrawingTool = tool
|
||||
if let color { currentDrawingColor = color }
|
||||
visibleDrawingPages.forEach { $0.setDrawingTool(tool, color: color, lineWidth: currentDrawingLineWidth) }
|
||||
}
|
||||
|
||||
private func showDrawingLayers() {
|
||||
let page = activeDrawingPage ?? visibleDrawingPages.first
|
||||
guard let page else { return }
|
||||
let panel = RDPDFReaderDrawingLayersView()
|
||||
panel.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
|
||||
panel.addHandler = { [weak self, weak page, weak panel] in
|
||||
page?.addDrawingLayer()
|
||||
guard let page else { return }
|
||||
panel?.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
|
||||
self?.activeDrawingPage = page
|
||||
}
|
||||
panel.selectHandler = { [weak page, weak panel] id in
|
||||
page?.selectDrawingLayer(id: id)
|
||||
guard let page else { return }
|
||||
panel?.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
|
||||
}
|
||||
panel.visibilityHandler = { [weak page, weak panel] id, visible in
|
||||
page?.setDrawingLayerVisibility(id: id, isVisible: visible)
|
||||
guard let page else { return }
|
||||
panel?.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
|
||||
}
|
||||
panel.deleteHandler = { [weak page, weak panel] id in
|
||||
page?.deleteDrawingLayer(id: id)
|
||||
guard let page else { return }
|
||||
panel?.reload(layers: page.drawingLayers(), selectedLayerID: page.selectedDrawingLayerID())
|
||||
}
|
||||
panel.show(in: view, above: drawingToolbar)
|
||||
}
|
||||
|
||||
/// 一笔画只能属于起笔时的实际 PDF 页。手指越过书脊后,画布会把点钳制在该页边缘,
|
||||
/// 绝不向相邻页续写,避免横屏双页中出现一条跨页笔迹。
|
||||
private func routeDrawingStroke(
|
||||
from source: RDPDFReaderPageView,
|
||||
point: CGPoint,
|
||||
phase: RDPDFReaderDrawingStrokePhase
|
||||
) {
|
||||
guard isDrawingMode else { return }
|
||||
// `RDPDFReaderDrawingCanvasView` 已对 source 页外的点执行 clamp;此处明确不做跨页转发。
|
||||
_ = (source, point)
|
||||
if phase == .ended || phase == .cancelled { continuedDrawingPage = nil }
|
||||
}
|
||||
|
||||
/// 手机上横屏高度有限,统一使用连续上下滚动;iPad 与手机竖屏保留用户选择。
|
||||
private var isPhoneLandscape: Bool {
|
||||
if let phoneLandscapeTransitionOverride { return phoneLandscapeTransitionOverride }
|
||||
return traitCollection.userInterfaceIdiom == .phone && view.bounds.width > view.bounds.height
|
||||
}
|
||||
|
||||
/// 手机横屏以“纸张浮在浅灰阅读台上”的方式呈现,便于明确区分 PDF 实际页面与留白。
|
||||
private var pageSurroundingColor: UIColor {
|
||||
isPhoneLandscape ? UIColor(white: 0.93, alpha: 1) : currentTheme.toolBackgroundColor
|
||||
}
|
||||
|
||||
private func applyDisplayTypeForCurrentInterface() {
|
||||
let effectiveType: RDPDFReaderView.DisplayType = isPhoneLandscape ? .verticalScroll : preferredDisplayType
|
||||
view.backgroundColor = isPhoneLandscape ? pageSurroundingColor : currentTheme.contentBackgroundColor
|
||||
readerView.backgroundColor = pageSurroundingColor
|
||||
// 先于 displayType 更新:横竖屏同为竖滑时不会走 switch,
|
||||
// 由该开关自身触发 cell 重建以套用宽度适配与新的 cell 高度。
|
||||
readerView.verticalScrollWidthFitEnabled = isPhoneLandscape
|
||||
guard readerView.currentDisplayType != effectiveType else {
|
||||
refreshLandscapePageAppearance()
|
||||
return
|
||||
}
|
||||
readerView.switchReaderDisplayType(effectiveType)
|
||||
refreshLandscapePageAppearance()
|
||||
}
|
||||
|
||||
private func refreshLandscapePageAppearance() {
|
||||
// 横竖屏同为竖滑时 ReaderView 不会重建 cell,须主动更新所有已显示页
|
||||
// 的适配模式与周边颜色(预加载的相邻页同样带着旧配置)。
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.readerView.currentPage >= 0 else { return }
|
||||
for view in self.readerView.visiblePageContentViews() {
|
||||
guard let page = view as? RDPDFReaderPageView, page.tag >= 0 else { continue }
|
||||
self.configure(page, at: page.tag)
|
||||
}
|
||||
// 旋转落定后安全区可能变化,宽度适配的 cell 高度随之更新。
|
||||
self.readerView.invalidateWidthFitLayoutIfNeeded()
|
||||
self.readerView.refreshCurrentPageIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func showAnnotations() {
|
||||
guard let annotationPersistence else { return }
|
||||
let panel = RDPDFReaderAnnotationListViewController { (try? annotationPersistence.loadAnnotations()) ?? [] }
|
||||
panel.onSelectAnnotation = { [weak self] item in self?.readerView.transitionToPage(pageNum: item.pageIndex, animated: false) }
|
||||
panel.onDeleteAnnotation = { [weak self] item in self?.delete(item) }
|
||||
RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .navigation, hidesNavigationBar: false)
|
||||
}
|
||||
|
||||
private func apply(_ theme: RDPDFReaderThemeOption) {
|
||||
currentTheme = theme
|
||||
view.backgroundColor = theme.contentBackgroundColor
|
||||
readerView.backgroundColor = pageSurroundingColor
|
||||
applyChromeTheme()
|
||||
if readerView.currentPage >= 0 { refreshVisiblePage(readerView.currentPage); readerView.refreshCurrentPageIfNeeded() }
|
||||
}
|
||||
|
||||
private func applyChromeTheme() {
|
||||
topToolbar?.apply(backgroundColor: currentTheme.toolBackgroundColor, tintColor: currentTheme.toolControlTextColor, separatorColor: currentTheme.toolLineColor)
|
||||
bottomToolbar?.apply(backgroundColor: currentTheme.toolBackgroundColor, tintColor: currentTheme.toolControlTextColor, separatorColor: currentTheme.toolLineColor)
|
||||
}
|
||||
|
||||
private func add(_ selection: RDPDFReaderImageTextSelection, page: Int, color: String = RDPDFReaderPageView.defaultHighlightColor, note: String?) {
|
||||
guard let annotationPersistence else { return }
|
||||
do { _ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source)); refreshVisiblePage(page) }
|
||||
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
|
||||
}
|
||||
|
||||
private func delete(_ annotation: RDPDFReaderAnnotation) {
|
||||
do { _ = try annotationPersistence?.deleteAnnotation(id: annotation.id); refreshVisiblePage(annotation.pageIndex) }
|
||||
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
|
||||
}
|
||||
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?) {}
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {}
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) }
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) }
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) }
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction, highlight: RDPDFReaderAnnotation) { if action == .deleteUnderline { delete(highlight) } else if action == .deleteAnnotation { var item = highlight; item.note = nil; do { _ = try annotationPersistence?.updateAnnotation(item); refreshVisiblePage(item.pageIndex) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } else if action == .annotate { presentEditor(annotation: highlight) } }
|
||||
|
||||
private func presentEditor(selection: RDPDFReaderImageTextSelection, page: Int) {
|
||||
let editor = RDPDFReaderAnnotationEditorViewController(quote: selection.text?.isEmpty == false ? selection.text! : "区域标注", theme: currentTheme, onSave: { [weak self] note in self?.add(selection, page: page, note: note) })
|
||||
presentAnnotationEditor(editor)
|
||||
}
|
||||
|
||||
private func presentEditor(annotation: RDPDFReaderAnnotation) {
|
||||
let editor = RDPDFReaderAnnotationEditorViewController(quote: annotation.selectedText?.isEmpty == false ? annotation.selectedText! : "区域标注", initialNote: annotation.note, theme: currentTheme, onSave: { [weak self] note in
|
||||
var item = annotation
|
||||
item.note = note
|
||||
do {
|
||||
_ = try self?.annotationPersistence?.updateAnnotation(item)
|
||||
self?.refreshVisiblePage(item.pageIndex)
|
||||
} catch {
|
||||
if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
|
||||
}
|
||||
})
|
||||
presentAnnotationEditor(editor)
|
||||
}
|
||||
|
||||
/// 与 EPUB 一致:注释编辑器是系统 page sheet,不使用阅读器的目录/设置底部面板。
|
||||
private func presentAnnotationEditor(_ editor: RDPDFReaderAnnotationEditorViewController) {
|
||||
let navigationController = UINavigationController(rootViewController: editor)
|
||||
navigationController.modalPresentationStyle = .pageSheet
|
||||
present(navigationController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol RDPDFReaderViewControllerDelegate: AnyObject {
|
||||
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController)
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int)
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error)
|
||||
}
|
||||
|
||||
public extension RDPDFReaderViewControllerDelegate {
|
||||
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) {}
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) {}
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import UIKit
|
||||
|
||||
/// 可嵌入任意页面内容的缩放画布,是 SDK 和主工程共用的唯一手势内核。
|
||||
public final class RDPDFZoomSurfaceView: UIView, UIScrollViewDelegate {
|
||||
public enum ContentAlignment {
|
||||
case centered
|
||||
case leading
|
||||
case trailing
|
||||
}
|
||||
|
||||
public var contentHorizontalInsets: UIEdgeInsets = .zero {
|
||||
didSet { applyContentSize(resetZoom: false) }
|
||||
}
|
||||
|
||||
public let contentView = UIView()
|
||||
public var pinchGestureRecognizer: UIPinchGestureRecognizer? { scrollView.pinchGestureRecognizer }
|
||||
|
||||
/// 内容在最小缩放(完整适配)时的尺寸,由宿主按图片比例计算后传入。
|
||||
public var contentSize: CGSize = .zero {
|
||||
didSet {
|
||||
guard contentSize != oldValue else { return }
|
||||
applyContentSize(resetZoom: true)
|
||||
}
|
||||
}
|
||||
/// 双页阅读时用于消除中缝留白;单页默认居中。
|
||||
public var contentAlignment: ContentAlignment = .centered {
|
||||
didSet { updateContentInset() }
|
||||
}
|
||||
|
||||
public var maximumZoomScale: CGFloat {
|
||||
get { scrollView.maximumZoomScale }
|
||||
set { scrollView.maximumZoomScale = max(newValue, scrollView.minimumZoomScale) }
|
||||
}
|
||||
|
||||
public var isDrawingMode = false {
|
||||
didSet {
|
||||
scrollView.isDrawingMode = isDrawingMode
|
||||
scrollView.pinchGestureRecognizer?.isEnabled = internalGesturesEnabled
|
||||
scrollView.updateContentPanAvailability(isZoomed: isZoomed)
|
||||
}
|
||||
}
|
||||
|
||||
public var isZoomed: Bool { scrollView.isContentZoomed }
|
||||
public var zoomScale: CGFloat { scrollView.zoomScale }
|
||||
public var contentOffset: CGPoint { scrollView.contentOffset }
|
||||
public var zoomStateChanged: ((Bool) -> Void)?
|
||||
public var viewportChanged: ((CGFloat, CGPoint) -> Void)?
|
||||
/// 宽度优先适配时,内容可能在 1 倍基础比例下已高于屏幕,允许直接纵向浏览。
|
||||
public var allowsBaselineVerticalPan = false {
|
||||
didSet {
|
||||
scrollView.allowsBaselineVerticalPan = allowsBaselineVerticalPan
|
||||
scrollView.updateContentPanAvailability(isZoomed: isZoomed)
|
||||
}
|
||||
}
|
||||
|
||||
private let scrollView: ZoomScrollView = {
|
||||
let view = ZoomScrollView()
|
||||
view.minimumZoomScale = 1
|
||||
view.maximumZoomScale = 3
|
||||
view.bouncesZoom = true
|
||||
view.showsHorizontalScrollIndicator = false
|
||||
view.showsVerticalScrollIndicator = false
|
||||
view.delaysContentTouches = false
|
||||
view.canCancelContentTouches = false
|
||||
view.isDirectionalLockEnabled = true
|
||||
return view
|
||||
}()
|
||||
private var reportedZoomState = false
|
||||
private var externalPinchStartScale: CGFloat = 1
|
||||
private var externalPinchAnchor = CGPoint.zero
|
||||
private var externalPanStartOffset = CGPoint.zero
|
||||
private var internalGesturesEnabled = true
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor(white: 0.93, alpha: 1)
|
||||
clipsToBounds = true
|
||||
scrollView.delegate = self
|
||||
addSubview(scrollView)
|
||||
contentView.backgroundColor = .white
|
||||
scrollView.addSubview(contentView)
|
||||
scrollView.updateContentPanAvailability(isZoomed: false)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
backgroundColor = UIColor(white: 0.93, alpha: 1)
|
||||
clipsToBounds = true
|
||||
scrollView.delegate = self
|
||||
addSubview(scrollView)
|
||||
contentView.backgroundColor = .white
|
||||
scrollView.addSubview(contentView)
|
||||
scrollView.updateContentPanAvailability(isZoomed: false)
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
scrollView.frame = bounds
|
||||
applyContentSize(resetZoom: false)
|
||||
}
|
||||
|
||||
public func resetZoom(animated: Bool = false) {
|
||||
scrollView.setZoomScale(scrollView.minimumZoomScale, animated: animated)
|
||||
if !animated { reportZoomState(false) }
|
||||
}
|
||||
|
||||
public func toggleZoom(around point: CGPoint, animated: Bool = true) {
|
||||
if isZoomed {
|
||||
resetZoom(animated: animated)
|
||||
return
|
||||
}
|
||||
let targetScale = min(2, scrollView.maximumZoomScale)
|
||||
guard targetScale > scrollView.minimumZoomScale else { return }
|
||||
if animated { reportZoomState(true) }
|
||||
let size = CGSize(width: bounds.width / targetScale, height: bounds.height / targetScale)
|
||||
let rect = CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height)
|
||||
scrollView.zoom(to: rect, animated: animated)
|
||||
if !animated { reportZoomState(isZoomed) }
|
||||
}
|
||||
|
||||
public func setInternalGesturesEnabled(_ enabled: Bool) {
|
||||
internalGesturesEnabled = enabled
|
||||
scrollView.pinchGestureRecognizer?.isEnabled = enabled
|
||||
scrollView.allowsContentPan = enabled
|
||||
scrollView.updateContentPanAvailability(isZoomed: isZoomed)
|
||||
}
|
||||
|
||||
public func beginExternalPinch(at point: CGPoint) {
|
||||
externalPinchStartScale = scrollView.zoomScale
|
||||
externalPinchAnchor = contentView.convert(point, from: self)
|
||||
}
|
||||
|
||||
public func updateExternalPinch(scale: CGFloat, at point: CGPoint) {
|
||||
let targetScale = min(
|
||||
scrollView.maximumZoomScale,
|
||||
max(scrollView.minimumZoomScale, externalPinchStartScale * scale)
|
||||
)
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
let viewportSize = CGSize(
|
||||
width: bounds.width / targetScale,
|
||||
height: bounds.height / targetScale
|
||||
)
|
||||
let normalizedX = min(1, max(0, point.x / bounds.width))
|
||||
let normalizedY = min(1, max(0, point.y / bounds.height))
|
||||
let zoomRect = CGRect(
|
||||
x: externalPinchAnchor.x - viewportSize.width * normalizedX,
|
||||
y: externalPinchAnchor.y - viewportSize.height * normalizedY,
|
||||
width: viewportSize.width,
|
||||
height: viewportSize.height
|
||||
)
|
||||
scrollView.zoom(to: zoomRect, animated: false)
|
||||
}
|
||||
|
||||
public func endExternalPinch() {
|
||||
reportZoomState(isZoomed)
|
||||
scrollView.updateContentPanAvailability(isZoomed: isZoomed)
|
||||
updateContentInset()
|
||||
}
|
||||
|
||||
public func beginExternalPan() {
|
||||
externalPanStartOffset = scrollView.contentOffset
|
||||
}
|
||||
|
||||
public func updateExternalPan(translation: CGPoint) {
|
||||
guard isZoomed else { return }
|
||||
let proposed = CGPoint(
|
||||
x: externalPanStartOffset.x - translation.x,
|
||||
y: externalPanStartOffset.y - translation.y
|
||||
)
|
||||
let inset = scrollView.adjustedContentInset
|
||||
let minX = -inset.left
|
||||
let minY = -inset.top
|
||||
let maxX = max(minX, scrollView.contentSize.width - scrollView.bounds.width + inset.right)
|
||||
let maxY = max(minY, scrollView.contentSize.height - scrollView.bounds.height + inset.bottom)
|
||||
scrollView.setContentOffset(
|
||||
CGPoint(
|
||||
x: min(maxX, max(minX, proposed.x)),
|
||||
y: min(maxY, max(minY, proposed.y))
|
||||
),
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
|
||||
public func endExternalPan() {
|
||||
viewportChanged?(scrollView.zoomScale, scrollView.contentOffset)
|
||||
}
|
||||
|
||||
public func viewForZooming(in scrollView: UIScrollView) -> UIView? { contentView }
|
||||
|
||||
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
if isZoomed { reportZoomState(true) }
|
||||
self.scrollView.updateContentPanAvailability(isZoomed: isZoomed)
|
||||
updateContentInset()
|
||||
viewportChanged?(scrollView.zoomScale, scrollView.contentOffset)
|
||||
}
|
||||
|
||||
public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
reportZoomState(isZoomed)
|
||||
self.scrollView.updateContentPanAvailability(isZoomed: isZoomed)
|
||||
updateContentInset()
|
||||
viewportChanged?(scrollView.zoomScale, scrollView.contentOffset)
|
||||
}
|
||||
|
||||
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard isZoomed else { return }
|
||||
viewportChanged?(scrollView.zoomScale, scrollView.contentOffset)
|
||||
}
|
||||
|
||||
private func applyContentSize(resetZoom: Bool) {
|
||||
guard contentSize.width > 0, contentSize.height > 0 else { return }
|
||||
if resetZoom {
|
||||
scrollView.setZoomScale(scrollView.minimumZoomScale, animated: false)
|
||||
reportZoomState(false)
|
||||
}
|
||||
// 捏合过程中 UIScrollView 会以 transform 改变 contentView 的 frame;不能在每次
|
||||
// layout 中重写 frame,否则会中断缩放后的拖动。仅在基础 bounds 真正变化时更新。
|
||||
let availableWidth = max(0, scrollView.bounds.width - contentHorizontalInsets.left - contentHorizontalInsets.right - contentSize.width)
|
||||
let originX: CGFloat
|
||||
switch contentAlignment {
|
||||
case .centered: originX = contentHorizontalInsets.left + availableWidth / 2
|
||||
case .leading: originX = contentHorizontalInsets.left
|
||||
case .trailing: originX = scrollView.bounds.width - contentHorizontalInsets.right - contentSize.width
|
||||
}
|
||||
let frame = CGRect(x: originX, y: 0, width: contentSize.width, height: contentSize.height)
|
||||
if contentView.frame != frame {
|
||||
contentView.frame = frame
|
||||
// 保留空出的横向空间为滚动坐标系的一部分;这会把内容真正放到指定边缘,
|
||||
// 而不是仅通过 inset 造成视觉上的居中。
|
||||
scrollView.contentSize = CGSize(width: max(scrollView.bounds.width, frame.maxX), height: contentSize.height)
|
||||
}
|
||||
if resetZoom {
|
||||
// 必须在写入新的 contentSize 之后再评估,否则拖动开关基于旧尺寸判定,
|
||||
// 且在下一次缩放事件之前不会有人重新计算。
|
||||
scrollView.updateContentPanAvailability(isZoomed: false)
|
||||
}
|
||||
updateContentInset()
|
||||
}
|
||||
|
||||
private func updateContentInset() {
|
||||
// 横向位置由 `applyContentSize` 的真实 frame 决定;不能再叠加 inset,
|
||||
// 否则 UIScrollView 会把双页重新向中轴居中。
|
||||
let vertical = max(0, (scrollView.bounds.height - contentView.frame.height) / 2)
|
||||
scrollView.contentInset = UIEdgeInsets(top: vertical, left: 0, bottom: vertical, right: 0)
|
||||
}
|
||||
|
||||
private func reportZoomState(_ zoomed: Bool) {
|
||||
guard zoomed != reportedZoomState else { return }
|
||||
reportedZoomState = zoomed
|
||||
zoomStateChanged?(zoomed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅管理图片页的缩放与拖动;不包含下载、缓存、数据库或业务手势。
|
||||
public final class RDPDFZoomablePageView: UIView {
|
||||
public enum PageFitMode {
|
||||
case aspectFit
|
||||
case fitAvailableWidth
|
||||
}
|
||||
/// 业务层可把图片、标注、链接和画笔叠加层都放入此视图,以保证缩放坐标一致。
|
||||
public var contentView: UIView { zoomSurface.contentView }
|
||||
|
||||
public var image: UIImage? {
|
||||
didSet {
|
||||
imageView.image = image
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
public var maximumZoomScale: CGFloat {
|
||||
get { zoomSurface.maximumZoomScale }
|
||||
set { zoomSurface.maximumZoomScale = newValue }
|
||||
}
|
||||
|
||||
public var contentAlignment: RDPDFZoomSurfaceView.ContentAlignment {
|
||||
get { zoomSurface.contentAlignment }
|
||||
set { zoomSurface.contentAlignment = newValue }
|
||||
}
|
||||
|
||||
/// 手机横屏单页使用宽度优先适配;其余阅读方式保持完整纸张的等比适配。
|
||||
public var pageFitMode: PageFitMode = .aspectFit {
|
||||
didSet { previousBounds = .zero; setNeedsLayout() }
|
||||
}
|
||||
public var contentHorizontalInsets: UIEdgeInsets = .zero {
|
||||
didSet {
|
||||
zoomSurface.contentHorizontalInsets = contentHorizontalInsets
|
||||
previousBounds = .zero
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
public var allowsBaselineVerticalPan: Bool {
|
||||
get { zoomSurface.allowsBaselineVerticalPan }
|
||||
set { zoomSurface.allowsBaselineVerticalPan = newValue }
|
||||
}
|
||||
|
||||
public var isDrawingMode = false {
|
||||
didSet {
|
||||
zoomSurface.isDrawingMode = isDrawingMode
|
||||
// 画笔模式下保留双指缩放/拖动;单指由宿主叠加的画笔视图消费。
|
||||
}
|
||||
}
|
||||
|
||||
public var isZoomed: Bool { zoomSurface.isZoomed }
|
||||
public var zoomScale: CGFloat { zoomSurface.zoomScale }
|
||||
public var contentOffset: CGPoint { zoomSurface.contentOffset }
|
||||
public var pinchGestureRecognizer: UIPinchGestureRecognizer? { zoomSurface.pinchGestureRecognizer }
|
||||
public var doubleTapZoomGestureRecognizer: UITapGestureRecognizer { doubleTapGesture }
|
||||
public var zoomStateChanged: ((Bool) -> Void)? {
|
||||
didSet { bindZoomStateHandler() }
|
||||
}
|
||||
public var viewportChanged: ((CGFloat, CGPoint) -> Void)? {
|
||||
didSet { bindViewportHandler() }
|
||||
}
|
||||
|
||||
public let zoomSurface = RDPDFZoomSurfaceView()
|
||||
private let imageView: UIImageView = {
|
||||
let view = UIImageView()
|
||||
view.contentMode = .scaleToFill
|
||||
view.clipsToBounds = true
|
||||
return view
|
||||
}()
|
||||
private var doubleTapGesture: UITapGestureRecognizer!
|
||||
private var doubleTapZoomAnimated = true
|
||||
private var previousBounds = CGSize.zero
|
||||
private var previousImageSize = CGSize.zero
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setupViews()
|
||||
}
|
||||
|
||||
public override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
zoomSurface.frame = bounds
|
||||
|
||||
let imageSize = image?.size ?? bounds.size
|
||||
guard imageSize.width > 0, imageSize.height > 0, bounds.width > 0, bounds.height > 0 else { return }
|
||||
guard imageSize != previousImageSize || bounds.size != previousBounds else { return }
|
||||
|
||||
previousImageSize = imageSize
|
||||
previousBounds = bounds.size
|
||||
let availableWidth = max(1, bounds.width - contentHorizontalInsets.left - contentHorizontalInsets.right)
|
||||
let scale: CGFloat
|
||||
switch pageFitMode {
|
||||
case .aspectFit:
|
||||
scale = min(availableWidth / imageSize.width, bounds.height / imageSize.height)
|
||||
case .fitAvailableWidth:
|
||||
scale = availableWidth / imageSize.width
|
||||
}
|
||||
let size = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
|
||||
zoomSurface.contentSize = size
|
||||
imageView.frame = contentView.bounds
|
||||
}
|
||||
|
||||
public func resetZoom(animated: Bool = false) {
|
||||
zoomSurface.resetZoom(animated: animated)
|
||||
}
|
||||
|
||||
public func setInternalGesturesEnabled(_ enabled: Bool) {
|
||||
// pageCurl 的手势由外层阅读器统一调度。双击缩放在该模式下同步完成,
|
||||
// 避免 UIScrollView 的缩放动画与随后到来的内容拖动同时修改 offset。
|
||||
doubleTapZoomAnimated = enabled
|
||||
doubleTapGesture.isEnabled = enabled
|
||||
zoomSurface.setInternalGesturesEnabled(enabled)
|
||||
}
|
||||
|
||||
public func beginExternalPinch(at point: CGPoint) {
|
||||
zoomSurface.beginExternalPinch(at: convert(point, to: zoomSurface))
|
||||
}
|
||||
|
||||
public func updateExternalPinch(scale: CGFloat, at point: CGPoint) {
|
||||
zoomSurface.updateExternalPinch(scale: scale, at: convert(point, to: zoomSurface))
|
||||
}
|
||||
|
||||
public func endExternalPinch() {
|
||||
zoomSurface.endExternalPinch()
|
||||
}
|
||||
|
||||
public func beginExternalPan() {
|
||||
zoomSurface.beginExternalPan()
|
||||
}
|
||||
|
||||
public func updateExternalPan(translation: CGPoint) {
|
||||
zoomSurface.updateExternalPan(translation: translation)
|
||||
}
|
||||
|
||||
public func endExternalPan() {
|
||||
zoomSurface.endExternalPan()
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
backgroundColor = UIColor(white: 0.93, alpha: 1)
|
||||
clipsToBounds = true
|
||||
addSubview(zoomSurface)
|
||||
contentView.addSubview(imageView)
|
||||
|
||||
doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
|
||||
doubleTapGesture.numberOfTapsRequired = 2
|
||||
addGestureRecognizer(doubleTapGesture)
|
||||
}
|
||||
|
||||
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
let point = gesture.location(in: contentView)
|
||||
zoomSurface.toggleZoom(around: point, animated: doubleTapZoomAnimated)
|
||||
}
|
||||
|
||||
private func bindZoomStateHandler() {
|
||||
zoomSurface.zoomStateChanged = { [weak self] zoomed in
|
||||
self?.zoomStateChanged?(zoomed)
|
||||
}
|
||||
}
|
||||
|
||||
private func bindViewportHandler() {
|
||||
zoomSurface.viewportChanged = { [weak self] scale, offset in
|
||||
self?.viewportChanged?(scale, offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ZoomScrollView: UIScrollView {
|
||||
var allowsContentPan = true
|
||||
var allowsBaselineVerticalPan = false
|
||||
var isDrawingMode = false {
|
||||
didSet { panGestureRecognizer.minimumNumberOfTouches = isDrawingMode ? 2 : 1 }
|
||||
}
|
||||
|
||||
var isContentZoomed: Bool { zoomScale > minimumZoomScale + 0.01 }
|
||||
|
||||
func updateContentPanAvailability(isZoomed: Bool) {
|
||||
let hasBaselineOverflow = allowsBaselineVerticalPan && contentSize.height > bounds.height + 1
|
||||
let shouldEnable = (allowsContentPan && isZoomed) || hasBaselineOverflow
|
||||
guard panGestureRecognizer.isEnabled != shouldEnable else { return }
|
||||
panGestureRecognizer.isEnabled = shouldEnable
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user