将 PDF 成品阅读控制器下沉为 RDPDFReaderView SDK,并修复手机横屏单页连续竖滑
SDK 化收尾: - 新增 RDPDFReaderView 本地 pod(阅读容器、成品控制器、划线/注释、画笔、 目录/设置面板、主题与持久化契约),Demo 精简为 PDFKit 页面提供者 + 持久化宿主 - Podfile/工程接入 RDPDFReaderView 与 SnapKit,示例书改用 PDF 阅读器示例文件 手机横屏单页竖滑修复: - cell 高度改为宽度适配后的纸张高度(sizeForItemAt + 宿主按页提供纵横比), 纵向超出一屏的内容由外层 collectionView 连续滚动承接,不再被裁掉 - 页面内部不再开启同向嵌套的兜底竖向拖动,修复外层滚动被吞、无法滚到下一页 - 修复 applyContentSize 先评估拖动开关后写 contentSize 的顺序缺陷 - 页码按视口中心命中 cell 计算,补 didEndDragging 兜底;旋转重锚点与 transitionToPage 改用真实布局位置 - 横竖屏同为竖滑时也重建 cell 并刷新全部可见页的适配配置 - 横屏单页点击不再跳页,只显隐工具栏;翻页交给连续竖滑 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a17164d1a9
commit
1422461224
@@ -0,0 +1,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user