将 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,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)
}
}