# RDReaderView 功能实现逻辑 ## 1. 范围与目标 - 代码范围:`Sources/RDReaderView/` 根目录(6 个 Swift 文件) - 目标:说明分页阅读器容器如何管理四种显示模式、DataSource/Delegate 协议、翻页交互、工具栏动画、双屏适配和 RTL 支持。 - 主链路关键词:`RDReaderDataSource -> reloadData -> DisplayType 切换 -> 翻页/滚动 -> RDReaderDelegate.pageNum -> 工具栏显隐`。 ## 2. 关键对象职责 ### 2.1 核心容器 `RDReaderView` - 文件:`Sources/RDReaderView/RDReaderView.swift`(~717 行) - 入口方法:`reloadData()` - 职责: - 管理四种显示模式的视图层级切换 - 持有 `UIPageViewController`(pageCurl 模式)或 `UICollectionView`(滚动模式) - 处理点击手势(左/中/右三区域) - 管理工具栏(topToolView / bottomToolView)的显示/隐藏动画 - 检测横竖屏变化并触发重新布局 - 管理双屏配对逻辑(含封面页处理) - RTL 语言方向支持 ### 2.2 自定义布局 `RDReaderFlowLayout` - 文件:`Sources/RDReaderView/RDReaderFlowLayout.swift`(~387 行) - 职责: - 继承 `UICollectionViewFlowLayout`,为三种滚动模式提供布局计算 - 水平滚动:全屏宽 item,pagingEnabled - 垂直滚动:可变高度 item,累加计算 - 水平覆盖滚动:zIndex 分层 + 阴影效果模拟深度 - 封面感知帧计算:封面页全屏宽,后续页面两两配对半屏宽 ### 2.3 内容 Cell `RDReaderContentCell` - 文件:`Sources/RDReaderView/RDReaderContentCell.swift`(~37 行) - 职责: - `UICollectionViewCell` 子类,作为内容视图的薄壳宿主 - `containerView` 属性 setter 自动移除旧视图、添加新视图 - `layoutSubviews` 将 containerView 填满 contentView.bounds ### 2.4 页面子控制器 `RDReaderPageChildViewController` - 文件:`Sources/RDReaderView/RDReaderPageChildViewController.swift`(~65 行) - 职责: - 仅用于 pageCurl 模式,作为 `UIPageViewController` 的子控制器 - 持有 `contentView: UIView?` 和 `pageNum: Int` - `contentView` didSet 在 view 已加载时自动调用 `installContentView()` ### 2.5 手势控制器 `RDReaderGestureController` - 文件:`Sources/RDReaderView/RDReaderGestureController.swift`(~42 行) - 职责: - 当前为占位组件,存储 topToolView / bottomToolView 引用 - 实际手势逻辑在 `RDReaderView.tapCenter()` 中实现 ### 2.6 URL 入口 `RDURLReaderController` - 文件:`Sources/RDReaderView/RDURLReaderController.swift`(~105 行) - 职责: - 最简入口:传入 URL 即可打开书籍 - `.epub` 扩展名 → 创建 `RDEPUBReaderController(epubURL:configuration:)` - 其他扩展名 → 使用 `RDPlainTextBookBuilder` 构建 `RDEPUBTextBook`,然后创建 `RDEPUBReaderController(textBook:bookIdentifier:title:textFileURL:configuration:)` - 分页失败时回退到纯 `UITextView` 展示(尝试 UTF-8 → GBK → GB2312 解码) - 导航栏标题设为文件名(去掉扩展名) ## 3. 主流程(代码级) ### 3.1 协议定义 **RDReaderDataSource**(数据供给): ```swift func pageCountOfReaderView(readerView: RDReaderView) -> Int func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? @objc optional func topToolView(readerView: RDReaderView) -> UIView? @objc optional func bottomToolView(readerView: RDReaderView) -> UIView? ``` **RDReaderDelegate**(事件回调): ```swift func pageNum(readerView: RDReaderView, pageNum: Int) @objc optional func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) ``` ### 3.2 初始化与数据加载 1. 调用方创建 `RDReaderView`,设置 `dataSource` 和 `delegate`。 2. 调用 `reloadData()`。 3. `reloadData` 内部调用 `switchReaderDisplayType(currentDisplayType)` 重建视图层级。 4. 同时从 `dataSource` 获取 `topToolView` 和 `bottomToolView` 并添加到视图层级。 ### 3.3 四种显示模式切换 **pageCurl 模式**: - 创建 `UIPageViewController`(transitionStyle: .pageCurl) - 通过 `attachPageViewControllerIfNeeded()` 添加为父 VC 的 child VC - 横屏双页时重建 `UIPageViewController`(spineLocation: .mid, isDoubleSided: true) - RTL 时翻转导航方向 **horizontalScroll 模式**: - 移除 pageViewController,插入 `UICollectionView` - `RDReaderFlowLayout` 设置 scrollDirection = .horizontal, isPagingEnabled = true - itemSize 宽度 = 容器宽度 / pagesPerScreen - RTL 时对 collectionView 做 `scaleX: -1` 翻转,cell contentView 再翻转回来 **verticalScroll 模式**: - 同一 collectionView,scrollDirection = .vertical, isPagingEnabled = false - 页面高度可变,通过 `RDReaderFlowLayoutDataSoure.heigtOfVerticalScrollPage` 查询 - collectionViewContentSize 为所有页面高度之和 **horizontalCoverScroll 模式**: - 水平分页,但带封面滑动动画 - `layoutAttributesForElements` 中: - 仅计算当前页附近的窄窗口内的 item attributes - 当前页之前的 item zIndex = -1,当前及之后 zIndex = 1 - 顶层页面边缘添加阴影效果 ### 3.4 翻页交互 **点击手势**(`tapAction(tap:)`): - 屏幕分为左 1/3、中 1/3、右 1/3 三个区域 - 左区域:工具栏隐藏时翻上一页(RTL 时翻下一页);工具栏显示时触发 `tapCenter()` - 中区域:始终触发 `tapCenter()` - 右区域:工具栏隐藏时翻下一页(RTL 时翻上一页);工具栏显示时触发 `tapCenter()` **goNextPage() / goPreviousPage()**: - 双页模式使用 `adjacentDualPage(from:forward:)` 计算目标页 - 单页模式直接 +1 / -1 - 调用 `transitionToPage(pageNum:animated:true)` 执行跳转 **工具栏切换**(`tapCenter()`): - 显示时:topToolView 从上方滑入,bottomToolView 从下方滑入(CGAffineTransform translationY) - 隐藏时:反向动画 - 工具栏显示期间禁用 collectionView 和 pageViewController 的用户交互 ### 3.5 双页配对逻辑 **`dualPagePair(for pageNum:)`** 返回 `(left: Int, right: Int?)`: - 有封面页:封面页 → (coverIndex, nil);封面后的页面两两配对(coverIndex+1 与 coverIndex+2,coverIndex+3 与 coverIndex+4...) - 无封面页:标准偶奇配对(0+1, 2+3, 4+5...) **空白哨兵页**: - `blankPageNum = Int.max`,`blankEndPageNum = Int.max - 1` - 仅用于 pageCurl 双页模式,填充封面页独占或总页数为奇数时的右侧空白 ### 3.6 横竖屏变化处理 1. `layoutSubviews()` 检测 `previousIsLandscape` 与当前 `isLandscape` 的变化 2. 异步调用 `orientationChanged(isNowLandscape:)` 避免嵌套布局 3. 通知 delegate `readerViewOrientationWillChange` 4. 更新 `layout.isLandscapeDualPage` 和 `layout.coverPageIndex` 5. pageCurl 模式:重建 UIPageViewController(spineLocation 不可变)并跳转到保存的页面 6. 滚动模式:invalidate layout,reload data,强制布局,无动画滚动到保存的 offset ### 3.7 CollectionView DataSource / Layout Delegate **cellForItemAt**: - 用 `dataSource.pageIdentifier` 获取重用标识符 - 出队 `RDReaderContentCell` - 调用 `dataSource.pageContentView(pageNum:containerView:)` 传入 cell 已有的 containerView 以便复用 - RTL 水平模式对 cell.contentView 做 `scaleX: -1` 翻转 **pageNum(flowLayout:pageIndex:)**: - 布局检测到滚动中页面变化时更新 `currentPage` ## 4. 异常与边界处理 - `currentPage` 初始值为 -1,首次设置时 delegate 会收到回调 - 空白哨兵页(Int.max / Int.max-1)在 delegate 回调中被过滤,不会通知到外部 - pageCurl 模式下 collectionView 的 DataSource 方法存在但不被调用(collectionView 已从视图层级移除) - 横竖屏变化时异步处理避免嵌套 layoutSubviews - `UIView.ss_superViewController` 通过响应链查找最近的父 VC,用于正确挂载 pageViewController ## 5. 数据结构与字段映射 ### 5.1 显示模式 ```swift RDReaderView.DisplayType ├── .pageCurl // UIPageViewController 翻页效果 ├── .horizontalScroll // UICollectionView 水平滚动 ├── .verticalScroll // UICollectionView 垂直滚动 └── .horizontalCoverScroll // UICollectionView 水平覆盖动画 ``` ### 5.2 翻页方向 ```swift RDReaderView.PageDirection ├── .leftToRight // 默认,中文/英文 └── .rightToLeft // 日文漫画等 ``` ### 5.3 关键公开属性 | 属性 | 类型 | 默认值 | 用途 | |------|------|--------|------| | `dataSource` | `RDReaderDataSource?` | nil | 弱引用数据源 | | `delegate` | `RDReaderDelegate?` | nil | 弱引用事件监听 | | `currentDisplayType` | `DisplayType` | .pageCurl | 当前显示模式 | | `toolViewAnimationDuration` | `TimeInterval` | 0.3 | 工具栏动画时长 | | `landscapeDualPageEnabled` | `Bool` | false | 横屏双页 | | `pageDirection` | `PageDirection` | .leftToRight | 翻页方向 | | `coverPageIndex` | `Int?` | nil | 封面页索引 | | `currentPage` | `Int` | -1 | 当前页码(didSet 通知 delegate) | ### 5.4 计算属性 - `pagesPerScreen: Int`:横屏 + 双页启用 + 非垂直滚动 → 2;否则 1 - `isLandscape: Bool`:`bounds.width > bounds.height` - `hasCoverPage: Bool`:`coverPageIndex != nil` ## 6. 视图绑定规则 - 规则 1:内容视图通过 `register(contentView:contentViewWithReuseIdentifier:)` 注册,内部注册对应的 `RDReaderContentCell` - 规则 2:Cell 复用两层:`RDReaderContentCell`(UICollectionViewCell)宿主 `containerView`(实际内容视图) - 规则 3:`pageContentView(readerView:pageNum:containerView:)` 的 `containerView` 参数是从回收 cell 中取出的旧视图,允许数据源复用或重新渲染 - 规则 4:工具栏通过 `topToolView` / `bottomToolView` 协议方法提供,由 `RDReaderView` 管理显隐动画 - 规则 5:RTL 支持通过 collectionView `scaleX: -1` + cell contentView `scaleX: -1` 实现,文本正确显示 ## 7. 通知协作与回调机制 - 无 NotificationCenter 使用 - 所有通信通过协议和闭包: - `RDReaderDataSource`:数据供给(页数、内容视图、标识符、工具栏) - `RDReaderDelegate`:事件通知(页面变化、横竖屏即将变化) - `RDReaderFlowLayoutDelegate`:布局检测到页面变化时通知 - `RDReaderFlowLayoutDataSoure`:垂直滚动时提供每页高度 ## 8. 联调与排查建议 - 排查 1:页面不显示 - 确认 `dataSource` 已设置且 `reloadData()` 已调用 - 确认 `pageCountOfReaderView` 返回值 > 0 - 确认 `pageContentView(pageNum:containerView:)` 返回非 nil 视图 - 排查 2:翻页无反应 - 确认工具栏是否正在显示(工具栏显示时左右点击变为切换工具栏) - 确认 `currentPage` 的 didSet 是否被触发 - pageCurl 模式检查 `UIPageViewController` 的 dataSource 方法是否正确返回 - 排查 3:横屏双页异常 - 确认 `landscapeDualPageEnabled` 是否为 true - pageCurl 模式需重建 UIPageViewController(spineLocation 不可变) - 检查 `dualPagePair` 配对逻辑和空白哨兵页处理 - 排查 4:RTL 方向错误 - 确认 `pageDirection` 是否设为 `.rightToLeft` - 水平滚动模式检查 collectionView 是否做了 `scaleX: -1` - pageCurl 模式检查导航方向是否翻转 - 排查 5:工具栏动画异常 - 确认 `topToolView` / `bottomToolView` 是否通过 dataSource 正确提供 - 检查 `toolViewAnimationDuration` 值 - 确认工具栏显示期间用户交互是否被正确禁用/恢复