将 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:
shenlei
2026-07-15 18:49:44 +09:00
co-authored by Claude Fable 5
parent a17164d1a9
commit 1422461224
347 changed files with 9912 additions and 3941 deletions
@@ -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
+32
View File
@@ -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)
}
/// 使 CatmullRom
/// /线
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 CatmullRom 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
}
}