13 KiB
横竖屏切换支持方案讨论
需求背景
Doc/EPUB_MAINTENANCE.md 与 Doc/ARCHITECTURE.md 都把“横竖屏切换支持”列为当前阅读器的高优先级待补能力。
目标不是单纯“屏幕旋转后不崩”,而是让 EPUB 阅读器在横竖屏切换后具备以下稳定行为:
- 正文重新按新视口尺寸分页或重排
- 阅读位置尽量准确恢复,不明显跳章、跳页
- 工具栏、目录、标注、搜索状态不异常
- pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种展示模式行为一致
- WebView、DTCoreText、fixed layout 三条正文路径都能正确处理
代码事实
已有基础
当前仓库并不是完全没有横竖屏处理基础:
RDReaderView.layoutSubviews()会检测previousIsLandscape与isLandscape的变化
文件:Sources/RDReaderView/RDReaderView.swiftRDReaderView.orientationChanged(isNowLandscape:)已能:- 通知
readerViewOrientationWillChange - 更新
layout.isLandscapeDualPage pageCurl模式下重建UIPageViewController- 滚动模式下
invalidateLayout + reloadData + setContentOffset
- 通知
RDEPUBReaderController.readerViewOrientationWillChange(...)当前会直接调用repaginatePreservingCurrentLocation()RDEPUBReaderController.repaginatePreservingCurrentLocation()已具备“先记录当前位置,再重新分页,再恢复位置”的主链路RDEPUBLocation已采用href + progression + lastProgression + fragment模型,本身适合应对字号变化和尺寸变化后的重定位
结论:
- 基础设施已经存在
- 真正缺的是“时机是否稳定、入口是否统一、三条正文路径是否都覆盖、是否会重复触发”
当前实现存在的问题
问题 1:方向变化回调被 landscapeDualPageEnabled 绑定
RDReaderView.layoutSubviews() 目前有:
guard landscapeDualPageEnabled, bounds.width > 0, bounds.height > 0 else { return }
这意味着:
- 只有开启
landscapeDualPageEnabled时才会进入方向变化检测 - 如果宿主关闭横屏双页,但正文仍然会因为宽高变化而需要重排,此时不会触发回调
这对以下场景不正确:
textReflowable文本书籍在横竖屏切换后,行宽必然变化,应重新分页webInteractive可重排正文在横竖屏切换后,列分页宽度变化,应重新分页- fixed layout 在 spread 模式下也可能因视口变化而需要重新生成页面模型
问题 2:重新分页触发时机偏早
当前 readerViewOrientationWillChange 是从 RDReaderView.layoutSubviews() 中异步抛出。
这有几个风险:
- 此时上层 VC 的
view.bounds、safe area、sheet 布局动画可能还在变化中 paginatePublication()如果过早读取currentLayoutContext().viewportSize,可能拿到中间态尺寸RDReaderView自己已经在同一轮变化里做了reloadData/transitionToPage,而RDEPUBReaderController又会发起新一轮分页,容易出现双重刷新
问题 3:职责边界不够清晰
目前横竖屏变化同时由两层在做事:
RDReaderView:处理双页布局、pageCurl 容器重建、滚动模式 offset 恢复RDEPUBReaderController:处理 EPUB 重新分页
但两层之间没有一个明确的“统一入口”来协调:
- 是否真的需要重分页
- 什么时候用最终尺寸重分页
- 何时忽略重复触发
- 何时只刷新布局,不重做完整分页
问题 4:文档状态与代码状态不一致
文档把横竖屏切换标为“未实现”,但代码里已经有半套逻辑。
这说明当前更准确的描述应当是:
- “已有局部实现”
- “尚未形成稳定、完整、可验收的横竖屏支持闭环”
需求拆解
为了让“横竖屏切换支持”真正完成,建议把需求拆成四部分:
1. 稳定感知视口变化
不仅要感知“横屏/竖屏布尔值变化”,还要感知:
- 宽高尺寸变化
- safe area 变化
- iPad 分屏、多窗口、sheet 尺寸变化
因此,真正要监听的不是“orientation”,而是“阅读视口发生了足以影响分页的变化”。
2. 统一触发重新分页
所有需要重排正文的场景都应走统一入口,例如:
handleViewportChange(reason: .orientationTransition)
由它统一负责:
- 读取当前位置
- 比较旧 viewport 与新 viewport
- 防抖与去重
- 发起分页
- 恢复定位
3. 区分“容器布局刷新”和“正文重分页”
不是所有变化都必须触发完整分页,但横竖屏切换通常都需要:
textReflowable:重建RDEPUBTextBookwebInteractive:重跑RDEPUBPaginatorwebFixedLayout:重建 fixed spread snapshot
而 RDReaderView 自己的 pageCurl 双页容器重建仍可保留,但不应和正文分页时机互相打架。
4. 建立回归验收矩阵
横竖屏切换是高联动场景,至少要验证:
- 无选区、无工具栏时切换
- 有目录面板、设置面板、标注列表时切换
- 搜索命中页、标注页、末页时切换
landscapeDualPageEnabled = true / false- pageCurl / scroll 系列模式
技术路线对比
路线 A:继续依赖 RDReaderView.readerViewOrientationWillChange
思路:
- 保留现有
layoutSubviews() -> orientationChanged -> delegate - 在
RDEPUBReaderController内增强去重、防抖和最终尺寸判断
优点:
- 改动范围小
- 能延续现有
RDReaderView双页逻辑
风险:
- 触发时机仍然偏依赖
layoutSubviews() - 仍容易与 VC 生命周期中的尺寸变化打架
- 继续把“正文分页”绑定到“容器方向回调”,抽象层次不够稳定
路线 B:由 RDEPUBReaderController.viewWillTransition(to:with:) 统一接管
思路:
- 在
RDEPUBReaderController实现viewWillTransition(to:with:) - 利用
transitionCoordinator等待旋转动画接近完成 - 在 completion 中读取最终
viewportSize - 统一调用
handleViewportChange(reason:) RDReaderView.readerViewOrientationWillChange只保留容器级双页重建职责,不再直接触发正文分页
优点:
- 分页时机更接近最终尺寸
- 更符合 UIKit 对旋转和尺寸变化的生命周期
- 更容易做重复触发抑制
- 更适合以后扩展到 iPad 分屏、多窗口
风险:
- 需要重新梳理
RDReaderView与RDEPUBReaderController的职责分界 - 需要验证 pageCurl 双页重建与正文分页之间的顺序
推荐结论
建议采用:
主路线:路线 B,由 RDEPUBReaderController.viewWillTransition(to:with:) 统一接管正文重分页
保留 RDReaderView 现有方向变化逻辑,但将其职责收敛为容器布局刷新和双页模式内部处理
原因:
- 需求本质是“视口变化后的正文重排”,最合理的宿主是控制器,而不是容器内部的
layoutSubviews() - 现有
RDReaderView回调机制已经能服务内部双页布局,但不适合作为 EPUB 正文分页的唯一入口 viewWillTransition+transitionCoordinator更容易拿到稳定尺寸,并减少重复分页
推荐方案
方案总览
引入一条统一的横竖屏处理主链路:
viewWillTransition(to:with:)
-> scheduleViewportTransition(reason: .orientation)
-> handleViewportChangeIfNeeded(finalViewportSize)
-> repaginatePreservingCurrentLocation()
-> finishPagination(restoreLocation:)
同时把 RDReaderView 内部职责收敛为:
- 更新
layout.isLandscapeDualPage - pageCurl 模式下重建
UIPageViewController - 滚动模式刷新 collectionView 布局
不再让 EPUBUI 直接在 readerViewOrientationWillChange 中立即重新分页。
关键改动建议
Task O1:增加控制器级旋转入口
文件:
Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
改动:
- 实现
viewWillTransition(to:with:) - 在
transitionCoordinatorcompletion 中比较新旧 viewport - 统一调
handleViewportChange(reason:)
完成定义:
- 旋转后分页使用最终尺寸,而不是中间态尺寸
Task O2:引入 viewport 去重与防抖
文件:
Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift
改动:
- 保存最近一次已应用的
viewportSignature - 仅当宽高变化超过阈值时才触发重新分页
- 防止
viewDidLayoutSubviews、orientation callback、viewWillTransition连续多次重复触发
推荐结构:
private struct RDEPUBViewportSignature: Equatable {
let width: CGFloat
let height: CGFloat
let safeTop: CGFloat
let safeBottom: CGFloat
}
完成定义:
- 一次横竖屏切换只触发一次有效正文重分页
Task O3:调整 readerViewOrientationWillChange 职责
文件:
Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift- 如有必要
Sources/RDReaderView/RDReaderView.swift
改动:
RDEPUBReaderController.readerViewOrientationWillChange不再直接repaginatePreservingCurrentLocation()- 保留它作为兼容钩子,或仅做轻量状态标记
完成定义:
- 容器内部布局刷新与正文分页不再相互打架
Task O4:放宽 RDReaderView 的方向检测前置条件
文件:
Sources/RDReaderView/RDReaderView.swift
改动:
- 去掉
layoutSubviews()中对landscapeDualPageEnabled的硬性 guard - 方向变化检测应独立于“是否启用横屏双页”
完成定义:
- 即使
landscapeDualPageEnabled == false,视口变化仍可被上层感知并触发重分页
注意事项:
- 双页布局本身仍然只在
landscapeDualPageEnabled == true时开启 - 但“是否需要感知尺寸变化”不能绑定到这个开关
Task O5:验证三条正文路径的恢复策略
文件:
Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swiftSources/RDReaderView/EPUBCore/RDEPUBReadingSession.swiftSources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift
改动:
- 验证
restoreReadingLocation()在以下路径都稳定:textReflowablewebInteractivewebFixedLayout
完成定义:
- 横竖屏切换后,恢复位置不明显跳页、跳章
Task O6:补文档与验收矩阵
文件:
Doc/EPUB_MAINTENANCE.mdDoc/ARCHITECTURE.md- 如有必要
Doc/EPUBUI_功能实现逻辑.md
改动:
- 将“横竖屏切换未做”更新为更准确的实现状态
- 补充最终采用的触发时机与回归清单
完成定义:
- 文档与代码状态一致
风险点
- pageCurl 双页重建顺序
RDReaderView自己会重建UIPageViewController,如果RDEPUBReaderController同时触发分页,需要验证两者先后顺序 - 重复分页
旋转时 UIKit 常伴随多次 layout / safe area 变化,如果没有
viewportSignature,很容易多次重排 - iPad 场景复杂度 分屏、多窗口、Stage Manager 下不一定发生“方向变化”,但一定发生“视口变化”,所以方案必须以 viewport 为核心
- fixed layout spread 恢复
横屏双页和竖屏单页之间切换时,恢复到哪一页的左页 / 右页,需要以
RDEPUBLocation为准而不是旧页号
推荐实施顺序
- 先做
O1 + O2,把控制器级 viewport 变化入口和防抖建起来 - 再做
O3 + O4,把旧的 delegate 触发职责收窄 - 然后验证
O5,重点跑三条正文路径和四种展示模式 - 最后做
O6,同步文档与验收矩阵
验收标准
- 横竖屏切换后,
textReflowable路径会按新尺寸重新分页,并恢复到接近原阅读位置 - 横竖屏切换后,
webInteractive路径会重新分页,并恢复到接近原阅读位置 - 横竖屏切换后,
webFixedLayout路径会按新 spread 规则重建页面模型,并恢复到正确资源 landscapeDualPageEnabled == false时,横竖屏切换仍然会触发必要的重排- 一次旋转只触发一次有效正文重分页,不出现连续多次闪烁刷新
- pageCurl / horizontalScroll / verticalScroll / horizontalCoverScroll 四种模式下都不出现明显错页、白页或工具栏错位
结论
横竖屏切换支持不是“从零开始新增一套能力”,而是要把当前分散在 RDReaderView 和 RDEPUBReaderController 里的半套逻辑收敛成一个稳定闭环。
最推荐的方向是:
RDEPUBReaderController用viewWillTransition(to:with:)统一接管正文级重分页RDReaderView保留容器级双页布局和 pageCurl 重建职责- 以
viewport变化而不是isLandscape布尔值作为真正的分页触发依据
这样既能复用现有代码,又能把“文档标注为未完成”的横竖屏需求收敛成可实现、可回归、可交付的一阶段方案。