From 736902b4895b64978a11fb991d3706dee49933c5 Mon Sep 17 00:00:00 2001 From: shen <> Date: Tue, 26 May 2026 21:37:39 +0800 Subject: [PATCH] fix(reader): stabilize pagination display and page curl reuse --- .../RDEPUBPageInteractionController.swift | 32 +++++++--- .../EPUBUI/RDEPUBPageLayoutSnapshot.swift | 5 +- .../EPUBUI/RDEPUBTextContentView.swift | 58 +++++++++++++++++-- .../ReaderView/RDReaderView.swift | 32 ++++++---- 4 files changed, 104 insertions(+), 23 deletions(-) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBPageInteractionController.swift b/Sources/RDReaderView/EPUBUI/RDEPUBPageInteractionController.swift index c02cb42..eef42e3 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBPageInteractionController.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBPageInteractionController.swift @@ -13,12 +13,18 @@ final class RDEPUBPageInteractionController { var snapshot: RDEPUBPageLayoutSnapshot? private var dtLayoutFrame: DTCoreTextLayoutFrame? + private var contentOffsetBase: Int = 0 #if canImport(DTCoreText) - func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) { + func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?, contentOffsetBase: Int = 0) { dtLayoutFrame = layoutFrame + self.contentOffsetBase = contentOffsetBase if let layoutFrame, let page { - snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page) + snapshot = RDEPUBPageLayoutSnapshot.build( + from: layoutFrame, + page: page, + contentOffsetBase: contentOffsetBase + ) } else { snapshot = nil } @@ -47,7 +53,11 @@ final class RDEPUBPageInteractionController { ) let idx = dtLine.stringIndex(forPosition: relativePoint) guard idx != NSNotFound, idx >= 0 else { return nil } - return normalizedIndex(idx, lineRange: line.stringRange, pageRange: snapshot.pageContentRange) + return normalizedIndex( + absoluteIndex(for: idx), + lineRange: line.stringRange, + pageRange: snapshot.pageContentRange + ) #else return nil #endif @@ -75,9 +85,9 @@ final class RDEPUBPageInteractionController { #if canImport(DTCoreText) guard let dtLine = dtLineContaining(range: line.stringRange) else { continue } - let startX = dtLine.offset(forStringIndex: overlap.location) + let startX = dtLine.offset(forStringIndex: localIndex(for: overlap.location)) let endIdx = overlap.location + overlap.length - let endX = dtLine.offset(forStringIndex: endIdx) + let endX = dtLine.offset(forStringIndex: localIndex(for: endIdx)) #else let startX: CGFloat = 0 let endX: CGFloat = line.frame.width @@ -135,7 +145,7 @@ final class RDEPUBPageInteractionController { #if canImport(DTCoreText) guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } - let offsetX = dtLine.offset(forStringIndex: index) + let offsetX = dtLine.offset(forStringIndex: localIndex(for: index)) #else let offsetX: CGFloat = 0 #endif @@ -187,10 +197,18 @@ final class RDEPUBPageInteractionController { #if canImport(DTCoreText) private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? { guard let dtLayoutFrame else { return nil } - return dtLayoutFrame.lineContaining(UInt(range.location)) + return dtLayoutFrame.lineContaining(UInt(max(localIndex(for: range.location), 0))) } #endif + private func localIndex(for absoluteIndex: Int) -> Int { + absoluteIndex - contentOffsetBase + } + + private func absoluteIndex(for localIndex: Int) -> Int { + localIndex + contentOffsetBase + } + private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] { guard rects.count > 1 else { return rects } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift b/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift index c78808e..9186c7b 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBPageLayoutSnapshot.swift @@ -130,7 +130,8 @@ struct RDEPUBPageLayoutSnapshot { #if canImport(DTCoreText) static func build( from layoutFrame: DTCoreTextLayoutFrame, - page: RDEPUBTextPage + page: RDEPUBTextPage, + contentOffsetBase: Int ) -> RDEPUBPageLayoutSnapshot? { guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else { return nil @@ -139,7 +140,7 @@ struct RDEPUBPageLayoutSnapshot { var lines: [RDEPUBPageLine] = [] var runs: [RDEPUBPageRun] = [] var attachments: [RDEPUBPageAttachment] = [] - let pageOffset = page.pageStartOffset + let pageOffset = contentOffsetBase for dtLine in dtLines { let lineRange = offset(dtLine.stringRange(), by: pageOffset) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift index 0d8063d..f0ab590 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBTextContentView.swift @@ -114,6 +114,7 @@ final class RDEPUBTextContentView: UIView { private var coreTextDisplayContent: NSAttributedString? private var coreTextDisplayRange: NSRange? + private var coreTextDisplayOffsetBase: Int = 0 #endif private let interactionController = RDEPUBPageInteractionController() @@ -256,7 +257,7 @@ final class RDEPUBTextContentView: UIView { coverImageView.isHidden = true coverImageView.image = nil - let selectionContent = NSMutableAttributedString(attributedString: page.content) + let selectionContent = normalizedPageContent(from: page) let selectionRange = NSRange(location: 0, length: selectionContent.length) selectionContent.addAttribute( .foregroundColor, @@ -265,7 +266,10 @@ final class RDEPUBTextContentView: UIView { ) #if canImport(DTCoreText) - let displayContent = NSMutableAttributedString(attributedString: page.content) + let usesChapterContextDisplay = shouldUseChapterContextDisplay(for: page) + let displayContent = usesChapterContextDisplay + ? NSMutableAttributedString(attributedString: page.chapterContent) + : normalizedPageContent(from: page) let fullRange = NSRange(location: 0, length: displayContent.length) displayContent.addAttribute( .foregroundColor, @@ -275,7 +279,8 @@ final class RDEPUBTextContentView: UIView { coreTextContentView.isHidden = false coreTextContentView.backgroundColor = .clear coreTextDisplayContent = displayContent - coreTextDisplayRange = NSRange(location: 0, length: displayContent.length) + coreTextDisplayRange = usesChapterContextDisplay ? page.contentRange : NSRange(location: 0, length: displayContent.length) + coreTextDisplayOffsetBase = usesChapterContextDisplay ? 0 : page.pageStartOffset textView.isHidden = true textView.isUserInteractionEnabled = false textView.attributedText = nil @@ -646,6 +651,47 @@ final class RDEPUBTextContentView: UIView { return proxy } + private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString { + let content = NSMutableAttributedString(attributedString: page.content) + guard shouldNormalizeContinuationParagraph(for: page) else { + return content + } + + let text = content.string as NSString + let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0)) + guard firstParagraphRange.length > 0 else { + return content + } + + content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in + guard let style = value as? NSParagraphStyle else { return } + let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() + mutableStyle.firstLineHeadIndent = mutableStyle.headIndent + mutableStyle.paragraphSpacingBefore = 0 + content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range) + } + + return content + } + + private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool { + let pageStart = page.pageStartOffset + guard pageStart > 0, pageStart < page.chapterContent.length else { + return false + } + + let chapterText = page.chapterContent.string as NSString + guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { + return false + } + + return !CharacterSet.newlines.contains(previousScalar) + } + + private func shouldUseChapterContextDisplay(for page: RDEPUBTextPage) -> Bool { + page.metadata.attachmentRanges.isEmpty + } + #if canImport(DTCoreText) private func updateCoreTextLayoutFrameIfNeeded() { guard !coreTextContentView.isHidden, @@ -666,7 +712,11 @@ final class RDEPUBTextContentView: UIView { layouter.shouldCacheLayoutFrames = false let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange) coreTextContentView.layoutFrame = layoutFrame - interactionController.configure(layoutFrame: layoutFrame, page: page) + interactionController.configure( + layoutFrame: layoutFrame, + page: page, + contentOffsetBase: coreTextDisplayOffsetBase + ) overlayView.updateSnapshot(interactionController.snapshot) backgroundOverlayView.updateSnapshot(interactionController.snapshot) } diff --git a/Sources/RDReaderView/ReaderView/RDReaderView.swift b/Sources/RDReaderView/ReaderView/RDReaderView.swift index 8c155a2..3f7485b 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView.swift @@ -624,19 +624,31 @@ public class RDReaderView: UIView { /// 获取用于显示的页面视图 /// 优先从缓存中获取,其次从预加载缓存中获取,最后新建 private func pageViewForDisplay(pageNum: Int) -> UIView { - if let cached = pageCurlCachedViews[pageNum] { - return cached - } - if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) { - preloaded.removeFromSuperview() - pageCurlCachedViews[pageNum] = preloaded - return preloaded - } - let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil) ?? UIView() + let reusableView = detachedReusablePageView(for: pageNum) + let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: reusableView) + ?? reusableView + ?? UIView() pageCurlCachedViews[pageNum] = view return view } + /// 返回可安全复用的 page view。 + /// 只有已经脱离视图层级的 view 才能复用,避免 pageCurl 转场期间同一个 UIView + /// 被多个 child controller 争用,从而导致前一个页面瞬时空白。 + private func detachedReusablePageView(for pageNum: Int) -> UIView? { + if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) { + preloaded.removeFromSuperview() + return preloaded + } + + guard let cached = pageCurlCachedViews[pageNum], cached.superview == nil else { + return nil + } + + pageCurlCachedViews.removeValue(forKey: pageNum) + return cached + } + /// 裁剪缓存,只保留指定页码集合中的页面 /// 不在保留集合中的页面会被从视图层级中移除 private func trimCachedPageViews(keeping pageNumbers: Set) { @@ -705,7 +717,7 @@ public class RDReaderView: UIView { preloadHostView.frame = bounds for targetPage in targets { - let existing = preloadedPageViews[targetPage] ?? pageCurlCachedViews.removeValue(forKey: targetPage) + let existing = detachedReusablePageView(for: targetPage) let contentView = dataSource?.pageContentView(readerView: self, pageNum: targetPage, containerView: existing) ?? existing ?? UIView() preloadedPageViews[targetPage] = contentView if contentView.superview !== preloadHostView {