Files
ReadViewSDK/Sources/RDReaderView/ReaderView/RDReaderFlowLayout.swift
T
shenandshen 54798ba578 refactor: 添加中文注释 + 优化模块结构
- 给全部 78 个 Swift 源文件添加详细的中文注释(文件级、类级、方法级)
- 删除 LegacyRDReaderController/ 死代码目录(16 文件 4592 行)
- 根目录翻页容器文件移入 ReaderView/ 目录
- Resources/ 移入 EPUBCore/Resources/(与使用者归属一致)
- RDEPUBTextIndexTable.swift 移入 EPUBTextRendering/(消除反向依赖)
- RDURLReaderController.swift 移入 EPUBUI/(入口控制器归入 UI 层)
- 更新 podspec 资源路径
2026-05-25 10:19:14 +08:00

376 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// RDReaderFlowLayout.swift
// RDReaderDemo
//
// Created by yangsq on 2021/8/6.
//
// 文件职责:自定义 UICollectionViewFlowLayout,支持阅读器的三种滚动布局模式。
// 该文件实现了水平滚动(全屏宽 item + 分页)、垂直滚动(可变高度)和
// 封面感知的双页布局(含封面页独占整屏逻辑)。
//
// 架构位置:RDReaderView 四层架构中第三层的布局组件,被 RDReaderView 持有和配置。
//
import UIKit
/// 流式布局数据源协议
/// 提供垂直滚动模式下各页面的高度信息
public protocol RDReaderFlowLayoutDataSoure: NSObjectProtocol {
/// 返回垂直滚动模式下指定页的高度
/// - Parameters:
/// - flowLayout: 发起请求的布局对象
/// - pageIndex: 页码索引
/// - Returns: 该页的高度,nil 表示使用默认高度(collectionView.frame.height
func heigtOfVerticalScrollPage(flowLayout: RDReaderFlowLayout, pageIndex: Int) -> CGFloat?
}
/// 流式布局代理协议
/// 当当前可见页码变化时通知外部
@objc public protocol RDReaderFlowLayoutDelegate: NSObjectProtocol {
/// 页码变化回调
/// - Parameters:
/// - flowLayout: 发起回调的布局对象
/// - pageIndex: 当前页码
func pageNum(flowLayout: RDReaderFlowLayout, pageIndex: Int)
}
/// 自定义流式布局,支持阅读器的三种滚动布局模式:
/// - horizontalScroll:水平滚动,每屏1项(竖屏)或2项(横屏双页),支持分页
/// - verticalScroll:垂直滚动,全宽项目,支持可变高度
///
/// 通过 ``displayType`` 属性切换布局模式。
/// 通过 ``isLandscapeDualPage`` 和 ``coverPageIndex`` 控制横屏双页和封面页逻辑。
public class RDReaderFlowLayout: UICollectionViewFlowLayout {
/// 布局模式,切换时自动使布局失效并重新计算
var displayType: RDReaderView.DisplayType = .horizontalScroll {
didSet {
invalidateLayout()
}
}
/// 是否启用横屏双页模式
/// 启用后水平滚动模式下每屏显示两项(各占半屏宽度)
var isLandscapeDualPage: Bool = false {
didSet {
if oldValue != isLandscapeDualPage {
invalidateLayout()
}
}
}
/// 封面页索引
/// 设置后封面页独占整屏,后续页面两两配对各占半屏
var coverPageIndex: Int? = nil {
didSet {
if oldValue != coverPageIndex {
invalidateLayout()
}
}
}
/// 上一次 prepare 时的 bounds 尺寸,用于检测尺寸变化
private var lastPreparedBoundsSize: CGSize = .zero
/// 每屏显示的页数
/// 仅在水平滚动+横屏双页模式下返回2,其他情况返回1
var pagesPerScreen: Int {
guard isLandscapeDualPage else { return 1 }
switch displayType {
case .horizontalScroll:
if let cv = collectionView, cv.bounds.width > cv.bounds.height {
return 2
}
return 1
default:
return 1
}
}
/// 流式布局数据源,提供垂直滚动模式的页面高度
weak var dataSource: RDReaderFlowLayoutDataSoure? = nil
/// 流式布局代理,接收页码变化通知
weak var delegate: RDReaderFlowLayoutDelegate? = nil
/// 是否在双页模式下有封面页独占
private var hasCoverPageInDualMode: Bool {
return pagesPerScreen > 1 && coverPageIndex != nil
}
/// 封面感知的帧计算:根据索引计算 item 的 frame
/// 封面页独占整屏,后续页面两两配对各占半屏
/// - Parameters:
/// - index: item 索引
/// - screenWidth: 屏幕宽度
/// - halfWidth: 半屏宽度
/// - height: 屏幕高度
/// - Returns: 该 item 的 frame
private func coverAwareFrame(for index: Int, screenWidth: CGFloat, halfWidth: CGFloat, height: CGFloat) -> CGRect {
guard let coverIndex = coverPageIndex else {
let pairIdx = index / 2
let side = index % 2
let x = CGFloat(pairIdx) * screenWidth + CGFloat(side) * halfWidth
return CGRect(x: x, y: 0, width: halfWidth, height: height)
}
if index == coverIndex {
let screensBeforeCover = coverIndex / 2
let x = CGFloat(screensBeforeCover) * screenWidth
return CGRect(x: x, y: 0, width: screenWidth, height: height)
}
let adjustedIndex = index - (coverIndex + 1)
let pairIdx = adjustedIndex / 2
let side = adjustedIndex % 2
let coverScreens = coverIndex / 2 + 1
let x = CGFloat(coverScreens + pairIdx) * screenWidth + CGFloat(side) * halfWidth
return CGRect(x: x, y: 0, width: halfWidth, height: height)
}
/// 封面感知的屏幕起始索引计算:根据滚动偏移量计算当前屏幕的第一个 item 索引
/// - Parameters:
/// - offset: 当前滚动偏移量
/// - screenWidth: 屏幕宽度
/// - Returns: 当前屏幕第一个 item 的索引
private func coverAwareStartIndex(for offset: CGFloat, screenWidth: CGFloat) -> Int {
let pps = pagesPerScreen
guard let coverIdx = coverPageIndex, hasCoverPageInDualMode else {
let screenIndex = Int((offset / screenWidth).rounded(.down))
return screenIndex * pps
}
let coverScreens = coverIdx / 2 + 1
let coverEnd = CGFloat(coverScreens) * screenWidth
if offset < coverEnd {
let screenIdx = Int((offset / screenWidth).rounded(.down))
return screenIdx >= coverIdx / 2 ? coverIdx : screenIdx * pps
} else {
let pairIdx = Int(((offset - coverEnd) / screenWidth).rounded(.down))
return coverIdx + 1 + pairIdx * 2
}
}
/// 当前可见的页码,值变化时通过 delegate 通知外部
private var currentPage: Int = 0 {
didSet {
if let delegate = delegate, delegate.responds(to: #selector(RDReaderFlowLayoutDelegate.pageNum(flowLayout:pageIndex:))), currentPage != oldValue, currentPage >= 0 {
delegate.pageNum(flowLayout: self, pageIndex: currentPage)
}
}
}
/// 初始化方法
/// - Parameter displayType: 初始布局模式
init(displayType: RDReaderView.DisplayType) {
self.displayType = displayType
super.init()
}
/// 布局准备阶段,配置 item 尺寸、滚动方向和分页行为
public override func prepare() {
super.prepare()
guard let collectionView = self.collectionView else {
return
}
guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
return
}
lastPreparedBoundsSize = collectionView.bounds.size
if #available(iOS 11.0, *) {
collectionView.contentInsetAdjustmentBehavior = .never
} else {
collectionView.ss_superViewController?.automaticallyAdjustsScrollViewInsets = false
}
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
minimumLineSpacing = 0
minimumInteritemSpacing = 0
switch self.displayType {
case .horizontalScroll:
scrollDirection = .horizontal
let columns = CGFloat(pagesPerScreen)
itemSize = CGSize(width: collectionView.frame.width / columns, height: collectionView.frame.height)
collectionView.isPagingEnabled = true
case .verticalScroll:
itemSize = CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
scrollDirection = .vertical
collectionView.isPagingEnabled = false
collectionView.showsVerticalScrollIndicator = true
default: break
}
}
/// 判断 bounds 变化是否需要使布局失效
/// 尺寸变化时总是返回 true,布局模式变化也返回 true
public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
if newBounds.size != lastPreparedBoundsSize {
return true
}
switch displayType {
case .verticalScroll:
return true
case .horizontalScroll:
return true
default:
return false
}
}
/// 计算 collectionView 的总内容尺寸
/// 垂直滚动模式:累加所有页面高度
/// 封面双页模式:封面占一屏 + 后续页面按两两配对计算屏幕数
public override var collectionViewContentSize: CGSize {
var size = super.collectionViewContentSize
guard let collectionView = collectionView else {
return size
}
if displayType == .verticalScroll {
let totalHeight = [Int](0..<collectionView.numberOfItems(inSection: 0)).reduce(0.0, { result, num in
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: num) ?? collectionView.frame.height)
})
size.height = totalHeight
}
if hasCoverPageInDualMode, displayType == .horizontalScroll {
let totalItems = collectionView.numberOfItems(inSection: 0)
let screenWidth = collectionView.frame.width
let remainingItems = max(0, totalItems - 1)
let pairedScreens = (remainingItems + 1) / 2
size.width = screenWidth * CGFloat(1 + pairedScreens)
}
return size
}
/// 计算指定矩形区域内的布局属性
/// 垂直滚动模式:根据可变高度定位每个 item
/// 水平滚动模式:根据当前偏移量计算当前页码,清除 cell 阴影
public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
var attributes = super.layoutAttributesForElements(in: rect)
guard let collectionView = collectionView else {
return attributes
}
if self.displayType == .verticalScroll {
let rowCount = collectionView.numberOfItems(inSection: 0)
var totalHeight: CGFloat = 0
for index in 0..<rowCount {
totalHeight += dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: index) ?? collectionView.frame.height
if totalHeight > collectionView.contentOffset.y {
self.currentPage = index
break
}
}
(attributes ?? []).forEach({ attr in
let indexPath = attr.indexPath
let lastRow = max(0, indexPath.row - 1)
let height = dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: indexPath.row) ?? collectionView.frame.height
var lastTotalHeight: CGFloat = 0
if indexPath.row > 0 {
lastTotalHeight = [Int](0...lastRow).reduce(0.0) { result, row in
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: row) ?? collectionView.frame.height)
}
}
if let cell = collectionView.cellForItem(at: indexPath) {
cell.layer.shadowOpacity = 0
}
attr.frame = CGRect(x: 0, y: lastTotalHeight, width: collectionView.frame.width, height: height)
})
}
if self.displayType == .horizontalScroll {
let pps = pagesPerScreen
if hasCoverPageInDualMode {
let screenWidth = collectionView.frame.width
let halfWidth = screenWidth / 2.0
let rows = collectionView.numberOfItems(inSection: 0)
self.currentPage = coverAwareStartIndex(for: collectionView.contentOffset.x, screenWidth: screenWidth)
var attrs = [UICollectionViewLayoutAttributes]()
for index in 0..<rows {
let frame = coverAwareFrame(for: index, screenWidth: screenWidth, halfWidth: halfWidth, height: collectionView.frame.height)
guard frame.intersects(rect) else { continue }
let indexPath = IndexPath(item: index, section: 0)
let attr = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attr.frame = frame
attrs.append(attr)
if let cell = collectionView.cellForItem(at: indexPath) {
cell.layer.shadowOpacity = 0
}
}
attributes = attrs
} else if pps > 1 {
let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down)) * pps
self.currentPage = currentPage
(attributes ?? []).forEach({ attr in
if let cell = collectionView.cellForItem(at: attr.indexPath) {
cell.layer.shadowOpacity = 0
}
})
} else {
let currentPage = Int((collectionView.contentOffset.x / collectionView.frame.width).rounded(.down))
self.currentPage = currentPage
(attributes ?? []).forEach({ attr in
if let cell = collectionView.cellForItem(at: attr.indexPath) {
cell.layer.shadowOpacity = 0
}
})
}
}
return attributes
}
/// 根据页码计算对应的 contentOffset
/// 用于跳转到指定页面时设置滚动位置
/// - Parameter count: 目标页码
/// - Returns: 对应的 contentOffset 坐标
func currentContentOffset(count: Int) -> CGPoint {
guard let collectionView = collectionView else {
return .zero
}
guard collectionView.frame.width > 0, collectionView.frame.height > 0 else {
return .zero
}
let safeCount = max(0, count)
switch displayType {
case .verticalScroll:
guard safeCount > 0 else { return .zero }
let totalHeight = [Int](0...(safeCount - 1)).reduce(0.0) { result, pageNum in
result + (dataSource?.heigtOfVerticalScrollPage(flowLayout: self, pageIndex: pageNum) ?? collectionView.frame.height)
}
return CGPoint(x: 0, y: totalHeight)
default:
let pps = pagesPerScreen
let screenWidth = collectionView.frame.width
if hasCoverPageInDualMode, let coverIdx = coverPageIndex {
if safeCount == coverIdx {
let screensBeforeCover = coverIdx / 2
return CGPoint(x: CGFloat(screensBeforeCover) * screenWidth, y: 0)
}
if safeCount < coverIdx {
return CGPoint(x: CGFloat(safeCount / 2) * screenWidth, y: 0)
}
let coverScreens = coverIdx / 2 + 1
let adjustedIndex = safeCount - (coverIdx + 1)
let pairIndex = adjustedIndex / 2
return CGPoint(x: CGFloat(coverScreens + pairIndex) * screenWidth, y: 0)
} else if pps > 1 {
let screenIndex = safeCount / pps
return CGPoint(x: CGFloat(screenIndex) * screenWidth, y: 0)
} else {
return CGPoint(x: CGFloat(safeCount) * itemSize.width, y: 0)
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}