- 设置页打开时仅重新计算当前章节预览,关闭后触发完整补全 - 新增 preview 加载优先级,支持设置页内当前章预览 - 添加设置页防抖机制,合并连续字号/行距变更 - 支持下滑手势关闭设置页,通过 presentation delegate 通知运行时 - 重新分页时跳过导航和尺寸校验,避免状态冲突 - currentTextPageSize 线程安全优化,后台线程使用缓存尺寸 - 更新 CocoaPods 依赖 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
383 lines
16 KiB
Swift
383 lines
16 KiB
Swift
// RDEPUBReaderController+DataSource.swift
|
||
// EPUB 阅读器数据源与代理实现
|
||
// 实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、
|
||
// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。
|
||
|
||
import UIKit
|
||
|
||
/// RDEPUBReaderController 数据源与代理扩展
|
||
///
|
||
/// 本文件实现 RDReaderDataSource 和 RDReaderDelegate 协议,为阅读器视图提供页面数量、
|
||
/// 页面内容视图、页码变化通知、屏幕方向变化处理等核心数据与事件支持。
|
||
|
||
// MARK: - RDReaderView 数据源与代理
|
||
|
||
extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
||
/// 返回阅读器总页数
|
||
public func pageCountOfReaderView(readerView: RDReaderView) -> Int {
|
||
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
|
||
}
|
||
|
||
/// 为指定页码创建或复用内容视图(优先文本渲染,回退 Web 渲染)
|
||
public func pageContentView(readerView: RDReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||
if readerContext.bookPageMap != nil {
|
||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1)
|
||
if let resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum) {
|
||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||
contentView.delegate = self
|
||
contentView.configure(
|
||
page: resolvedPage.page,
|
||
pageNumber: pageNum + 1,
|
||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||
configuration: configuration,
|
||
highlights: textHighlights(for: resolvedPage.page),
|
||
searchState: searchState(for: resolvedPage.page)
|
||
)
|
||
return contentView
|
||
}
|
||
}
|
||
|
||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||
contentView.delegate = self
|
||
contentView.configure(
|
||
page: page,
|
||
pageNumber: pageNum + 1,
|
||
totalPages: textBook.pages.count,
|
||
configuration: configuration,
|
||
highlights: textHighlights(for: page),
|
||
searchState: searchState(for: page)
|
||
)
|
||
return contentView
|
||
}
|
||
|
||
guard let publication,
|
||
let request = request(for: pageNum) else {
|
||
return containerView ?? UIView()
|
||
}
|
||
|
||
let contentView = (containerView as? RDEPUBWebContentView) ?? RDEPUBWebContentView()
|
||
contentView.delegate = self
|
||
contentView.configure(
|
||
publication: publication,
|
||
request: request,
|
||
pageNumber: pageNum + 1,
|
||
totalPages: activePages.count,
|
||
theme: configuration.theme
|
||
)
|
||
return contentView
|
||
}
|
||
|
||
/// 返回页面内容视图的重用标识符(区分文本与 Web 渲染)
|
||
public func pageIdentifier(readerView: RDReaderView, pageNum: Int) -> String? {
|
||
(textBook == nil && readerContext.bookPageMap == nil)
|
||
? NSStringFromClass(RDEPUBWebContentView.self)
|
||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||
}
|
||
|
||
/// 获取指定文本页面上的高亮标注列表
|
||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||
if let textBook,
|
||
let chapterData = textBook.chapterData(for: page.href) {
|
||
return chapterData.highlights(on: page, from: activeHighlights)
|
||
}
|
||
|
||
guard let publication else {
|
||
return activeHighlights.filter { $0.location.href == page.href }
|
||
}
|
||
let pageHref = publication.resourceResolver.normalizedHref(page.href) ?? page.href
|
||
return activeHighlights.filter {
|
||
(publication.resourceResolver.normalizedHref($0.location.href) ?? $0.location.href) == pageHref
|
||
}
|
||
}
|
||
|
||
/// 返回当前文本页需要展示的搜索状态。
|
||
/// 这里会先按页过滤命中结果,并把 currentMatchIndex 重映射到当前页内的局部索引,
|
||
/// 这样渲染层不需要再关心 href 规范化差异。
|
||
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
|
||
guard let globalSearchState = searchState else { return nil }
|
||
|
||
let matches: [RDEPUBSearchMatch]
|
||
let currentMatchIndex: Int?
|
||
|
||
if let chapterData = chapterData(for: page),
|
||
let resolvedState = resolvedSearchState(
|
||
for: page,
|
||
chapterData: chapterData,
|
||
globalSearchState: globalSearchState
|
||
) {
|
||
matches = resolvedState.matches
|
||
currentMatchIndex = resolvedState.currentMatchIndex
|
||
} else {
|
||
matches = globalSearchState.matches.filter { searchMatch in
|
||
searchMatchBelongsToPage(searchMatch, page: page)
|
||
}
|
||
currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
||
matches.firstIndex(of: currentMatch)
|
||
}
|
||
}
|
||
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
|
||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||
}
|
||
return RDEPUBSearchState(
|
||
keyword: globalSearchState.keyword,
|
||
matches: matches,
|
||
currentMatchIndex: currentMatchIndex
|
||
)
|
||
}
|
||
|
||
private func resolvedSearchState(
|
||
for page: RDEPUBTextPage,
|
||
chapterData: RDEPUBChapterData,
|
||
globalSearchState: RDEPUBSearchState
|
||
) -> RDEPUBSearchState? {
|
||
let normalizedKeyword = globalSearchState.keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !normalizedKeyword.isEmpty else {
|
||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||
}
|
||
|
||
let normalizedHref = normalizedPageHref(for: page)
|
||
let exactMatches = exactChapterSearchMatches(
|
||
in: chapterData,
|
||
keyword: normalizedKeyword,
|
||
normalizedHref: normalizedHref
|
||
)
|
||
let pageMatches = exactMatches.filter { match in
|
||
guard let rangeLocation = match.rangeLocation else { return false }
|
||
let range = NSRange(location: rangeLocation, length: max(match.rangeLength, 1))
|
||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||
}
|
||
|
||
let currentLocalMatchIndex = globalSearchState.currentMatch?.localMatchIndex
|
||
let currentMatchIndex = currentLocalMatchIndex.flatMap { localMatchIndex in
|
||
pageMatches.firstIndex(where: { $0.localMatchIndex == localMatchIndex })
|
||
}
|
||
|
||
guard !pageMatches.isEmpty || currentMatchIndex != nil else {
|
||
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||
}
|
||
|
||
return RDEPUBSearchState(
|
||
keyword: globalSearchState.keyword,
|
||
matches: pageMatches,
|
||
currentMatchIndex: currentMatchIndex
|
||
)
|
||
}
|
||
|
||
private func exactChapterSearchMatches(
|
||
in chapterData: RDEPUBChapterData,
|
||
keyword: String,
|
||
normalizedHref: String
|
||
) -> [RDEPUBSearchMatch] {
|
||
let source = chapterData.attributedContent.string as NSString
|
||
let fullLength = source.length
|
||
guard fullLength > 0 else { return [] }
|
||
|
||
var matches: [RDEPUBSearchMatch] = []
|
||
var localMatchIndex = 0
|
||
var searchRange = NSRange(location: 0, length: fullLength)
|
||
|
||
while searchRange.length > 0 {
|
||
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
|
||
guard foundRange.location != NSNotFound else { break }
|
||
|
||
let progressionDenominator = max(fullLength - 1, 1)
|
||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||
matches.append(
|
||
RDEPUBSearchMatch(
|
||
href: normalizedHref,
|
||
progression: progression,
|
||
previewText: previewText(in: source, matchRange: foundRange),
|
||
localMatchIndex: localMatchIndex,
|
||
rangeLocation: foundRange.location,
|
||
rangeLength: max(foundRange.length, 1),
|
||
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||
)
|
||
)
|
||
|
||
localMatchIndex += 1
|
||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
||
if nextLocation >= fullLength {
|
||
break
|
||
}
|
||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
||
}
|
||
|
||
return matches
|
||
}
|
||
|
||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
||
let previewRadius = 12
|
||
let start = max(matchRange.location - previewRadius, 0)
|
||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
||
let range = NSRange(location: start, length: max(end - start, 0))
|
||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
private func chapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData? {
|
||
if let textBook,
|
||
let chapterData = textBook.chapterData(for: page.href) {
|
||
return chapterData
|
||
}
|
||
|
||
guard let runtimeChapter = runtime.chapterRuntimeStore.chapterData(for: page.spineIndex) else {
|
||
return nil
|
||
}
|
||
return makeChapterData(from: runtimeChapter, chapterIndex: page.chapterIndex)
|
||
}
|
||
|
||
private func makeChapterData(
|
||
from runtimeChapter: RDEPUBRuntimeChapter,
|
||
chapterIndex: Int
|
||
) -> RDEPUBChapterData {
|
||
let textChapter = RDEPUBTextChapter(
|
||
chapterIndex: chapterIndex,
|
||
spineIndex: runtimeChapter.spineIndex,
|
||
href: runtimeChapter.href,
|
||
title: runtimeChapter.title,
|
||
attributedContent: runtimeChapter.typesetAttributedString,
|
||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||
pages: runtimeChapter.pages
|
||
)
|
||
return RDEPUBChapterData(
|
||
chapter: textChapter,
|
||
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
||
)
|
||
}
|
||
|
||
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
|
||
if let textBook,
|
||
let chapterData = textBook.chapterData(for: page.href),
|
||
let range = chapterData.absoluteRange(for: searchMatch) {
|
||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||
}
|
||
|
||
let pageHref = normalizedPageHref(for: page)
|
||
let matchHref = normalizedSearchHref(searchMatch.href)
|
||
guard pageHref == matchHref else { return false }
|
||
|
||
guard let rangeLocation = searchMatch.rangeLocation else {
|
||
return false
|
||
}
|
||
let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength)
|
||
return NSIntersectionRange(range, page.contentRange).length > 0
|
||
}
|
||
|
||
private func normalizedPageHref(for page: RDEPUBTextPage) -> String {
|
||
guard let publication else { return page.href }
|
||
return publication.resourceResolver.normalizedHref(page.href) ?? page.href
|
||
}
|
||
|
||
private func normalizedSearchHref(_ href: String) -> String {
|
||
guard let publication else { return href }
|
||
return publication.resourceResolver.normalizedHref(href) ?? href
|
||
}
|
||
|
||
/// 根据规范化 href 获取文本章节数据
|
||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||
readerContext.textChapterData(forNormalizedHref: href)
|
||
}
|
||
|
||
/// 返回阅读器顶部工具视图
|
||
public func topToolView(readerView: RDReaderView) -> UIView? {
|
||
topToolView
|
||
}
|
||
|
||
/// 返回阅读器底部工具视图
|
||
public func bottomToolView(readerView: RDReaderView) -> UIView? {
|
||
bottomToolView
|
||
}
|
||
|
||
/// 页码变化回调:清除选区、同步阅读状态、检测到达末尾
|
||
public func pageNum(readerView: RDReaderView, pageNum: Int) {
|
||
readerContext.markUserNavigationActivity()
|
||
updateCurrentSelection(nil)
|
||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||
|
||
// 如果正在重新分页,跳过后续操作,避免状态冲突
|
||
guard !isRepaginating else { return }
|
||
|
||
// 用户开始导航时,应用后台解析完成的完整 map
|
||
let previousCurrentPage = readerView.currentPage
|
||
runtime.applyPendingFullPageMapIfNeeded()
|
||
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
|
||
if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum {
|
||
return
|
||
}
|
||
|
||
if readerContext.bookPageMap != nil {
|
||
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: effectivePageNum + 1)
|
||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
|
||
}
|
||
|
||
let totalPages = pageCountOfReaderView(readerView: readerView)
|
||
if totalPages > 0, effectivePageNum == totalPages - 1 {
|
||
delegate?.epubReaderDidReachEnd(self)
|
||
}
|
||
|
||
if (textBook != nil || readerContext.bookPageMap != nil),
|
||
let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
|
||
persist(location: location)
|
||
synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
|
||
// 记录翻页到 JumpSession
|
||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||
return
|
||
}
|
||
|
||
if let location = fallbackLocation(for: effectivePageNum) {
|
||
persist(location: location)
|
||
}
|
||
if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving {
|
||
readingSession?.transition(to: .idle)
|
||
}
|
||
|
||
// 记录翻页到 JumpSession
|
||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||
}
|
||
|
||
/// 屏幕方向即将变化回调,捕获待恢复的展示位置
|
||
public func readerViewOrientationWillChange(readerView: RDReaderView, isLandscape: Bool) {
|
||
_ = isLandscape
|
||
runtime.viewportMonitor.capturePendingPresentationRestoreLocation()
|
||
}
|
||
|
||
/// 检测页面尺寸变化并触发重新分页(避免布局错乱)
|
||
private func reconcileTextPaginationSizeIfNeeded(for pageNum: Int) {
|
||
guard textBook != nil || readerContext.bookPageMap != nil,
|
||
!isRepaginating,
|
||
!isReconcilingTextPaginationSize,
|
||
pageNum >= 0,
|
||
let lastTextPaginationPageSize else {
|
||
return
|
||
}
|
||
|
||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
||
guard resolvedSize.width > 0,
|
||
resolvedSize.height > 0 else {
|
||
return
|
||
}
|
||
|
||
let sizeChanged = abs(resolvedSize.width - lastTextPaginationPageSize.width) > 0.5
|
||
|| abs(resolvedSize.height - lastTextPaginationPageSize.height) > 0.5
|
||
guard sizeChanged else {
|
||
return
|
||
}
|
||
|
||
isReconcilingTextPaginationSize = true
|
||
// 使用防抖延迟,避免连续快速点击字号时重复触发
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||
guard let self else { return }
|
||
self.isReconcilingTextPaginationSize = false
|
||
guard (self.textBook != nil || self.readerContext.bookPageMap != nil), !self.isRepaginating else { return }
|
||
// 再次检查尺寸是否仍然变化,避免过期的重新分页
|
||
let currentSize = self.readerView.resolvedSinglePageSize(pageNum: self.readerView.currentPage)
|
||
guard currentSize.width > 0, currentSize.height > 0 else { return }
|
||
let stillChanged = abs(currentSize.width - self.lastTextPaginationPageSize!.width) > 0.5
|
||
|| abs(currentSize.height - self.lastTextPaginationPageSize!.height) > 0.5
|
||
guard stillChanged else { return }
|
||
self.repaginatePreservingCurrentLocation()
|
||
}
|
||
}
|
||
}
|