refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvider, RDEpubReaderDelegate {
|
||||
|
||||
public func numberOfPages(in readerView: RDEpubReaderView) -> Int {
|
||||
effectiveNumberOfPages
|
||||
}
|
||||
|
||||
public func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
|
||||
pageContentView(readerView: readerView, pageNum: index, containerView: reusableView)
|
||||
}
|
||||
|
||||
public func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? {
|
||||
pageIdentifier(readerView: readerView, pageNum: index)
|
||||
}
|
||||
|
||||
public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? {
|
||||
topToolView(readerView: readerView)
|
||||
}
|
||||
|
||||
public func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView? {
|
||||
bottomToolView(readerView: readerView)
|
||||
}
|
||||
|
||||
public func pageCountOfReaderView(readerView: RDEpubReaderView) -> Int {
|
||||
readerContext.bookPageMap?.totalPages ?? textBook?.pages.count ?? activePages.count
|
||||
}
|
||||
|
||||
public func pageContentView(readerView: RDEpubReaderView, pageNum: Int, containerView: UIView?) -> UIView {
|
||||
// 试读墙页:位于最后一个可读页之后,展示宿主提供的墙视图
|
||||
if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
|
||||
return makeTrialWallPageView(reusableView: containerView)
|
||||
}
|
||||
|
||||
if readerContext.bookPageMap != nil {
|
||||
_ = runtime.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: pageNum + 1,
|
||||
allowSynchronousLoad: false
|
||||
)
|
||||
var resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
|
||||
let shouldAllowSynchronousFallback = (
|
||||
readerView.currentPage < 0 || readerView.currentPage == pageNum
|
||||
) && !readerView.isPageCurlTransitioning
|
||||
if resolvedPage == nil, shouldAllowSynchronousFallback {
|
||||
_ = runtime.prepareOnDemandChapter(
|
||||
forAbsolutePageNumber: pageNum + 1,
|
||||
allowSynchronousLoad: true
|
||||
)
|
||||
resolvedPage = runtime.pageResolver.resolvePage(absolutePageIndex: pageNum)
|
||||
}
|
||||
if let resolvedPage {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
|
||||
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.configure(
|
||||
page: resolvedPage.page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration,
|
||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
searchState: searchState(for: resolvedPage.page),
|
||||
displayCache: textDisplayCache
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
|
||||
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.configureLoading(
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
|
||||
if let textBook, let page = textBook.page(at: pageNum + 1) {
|
||||
let contentView = (containerView as? RDEPUBTextContentView) ?? RDEPUBTextContentView()
|
||||
contentView.delegate = self
|
||||
readerView.registerSelectionGestureDependenciesIfNeeded(for: contentView)
|
||||
contentView.selectionTapSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionTapSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.selectionPagingSuppressionDidChange = { [weak readerView, weak contentView] isSuppressed in
|
||||
guard let readerView, let contentView else { return }
|
||||
readerView.updateSelectionPagingSuppression(for: contentView, isSuppressed: isSuppressed)
|
||||
}
|
||||
contentView.configure(
|
||||
page: page,
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: textBook.pages.count,
|
||||
configuration: configuration,
|
||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||
highlights: textHighlights(for: page),
|
||||
searchState: searchState(for: page),
|
||||
displayCache: textDisplayCache
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
public func pageIdentifier(readerView: RDEpubReaderView, pageNum: Int) -> String? {
|
||||
if let wallIndex = trialWallPageIndex, pageNum == wallIndex {
|
||||
return NSStringFromClass(RDEPUBTrialWallContainerView.self)
|
||||
}
|
||||
return (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
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
|
||||
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
|
||||
}
|
||||
|
||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||
readerContext.textChapterData(forNormalizedHref: href)
|
||||
}
|
||||
|
||||
public func topToolView(readerView: RDEpubReaderView) -> UIView? {
|
||||
topToolView
|
||||
}
|
||||
|
||||
public func bottomToolView(readerView: RDEpubReaderView) -> UIView? {
|
||||
bottomToolView
|
||||
}
|
||||
|
||||
public func pageNum(readerView: RDEpubReaderView, pageNum: Int) {
|
||||
RDEPUBMemoryProbe.logPageTurn()
|
||||
readerContext.markUserNavigationActivity()
|
||||
updateCurrentSelection(nil)
|
||||
reconcileTextPaginationSizeIfNeeded(for: pageNum)
|
||||
|
||||
guard !isRepaginating else { return }
|
||||
|
||||
let previousCurrentPage = readerView.currentPage
|
||||
let wasPageCurlTransitioning = readerView.isPageCurlTransitioning
|
||||
runtime.applyPendingFullPageMapIfNeeded()
|
||||
if wasPageCurlTransitioning {
|
||||
DispatchQueue.main.async { [weak self, weak readerView] in
|
||||
guard let self, let readerView, !readerView.isPageCurlTransitioning else { return }
|
||||
self.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,
|
||||
allowSynchronousLoad: false
|
||||
)
|
||||
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
|
||||
}
|
||||
|
||||
// 翻到试读墙页:触发回调,跳过内容定位(墙页不属于书籍内容,无 location/CFI)
|
||||
if let wallIndex = trialWallPageIndex, effectivePageNum == wallIndex {
|
||||
delegate?.epubReaderDidReachTrialWall(self)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
if let location = fallbackLocation(for: effectivePageNum) {
|
||||
persist(location: location)
|
||||
}
|
||||
|
||||
runtime.locationCoordinator.recordPageChangeIfNeeded()
|
||||
}
|
||||
|
||||
public func readerViewOrientationWillChange(readerView: RDEpubReaderView, 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 }
|
||||
guard let previousPageSize = self.lastTextPaginationPageSize else { return }
|
||||
let stillChanged = abs(currentSize.width - previousPageSize.width) > 0.5
|
||||
|| abs(currentSize.height - previousPageSize.height) > 0.5
|
||||
guard stillChanged else { return }
|
||||
self.repaginatePreservingCurrentLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user