Epub阅读器0.0.1

This commit is contained in:
shen
2026-05-21 19:40:51 +08:00
commit daa36d8fe7
559 changed files with 106266 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
# 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`
- 其他 → 创建 `RDPlainTextReaderController`(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` 值
- 确认工具栏显示期间用户交互是否被正确禁用/恢复