// // 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.. [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.. 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.. 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") } }