fix(reader): stabilize pagination display and page curl reuse

This commit is contained in:
shen 2026-05-26 21:37:39 +08:00
parent 83b705b9ae
commit 736902b489
4 changed files with 104 additions and 23 deletions

View File

@ -13,12 +13,18 @@ final class RDEPUBPageInteractionController {
var snapshot: RDEPUBPageLayoutSnapshot? var snapshot: RDEPUBPageLayoutSnapshot?
private var dtLayoutFrame: DTCoreTextLayoutFrame? private var dtLayoutFrame: DTCoreTextLayoutFrame?
private var contentOffsetBase: Int = 0
#if canImport(DTCoreText) #if canImport(DTCoreText)
func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) { func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?, contentOffsetBase: Int = 0) {
dtLayoutFrame = layoutFrame dtLayoutFrame = layoutFrame
self.contentOffsetBase = contentOffsetBase
if let layoutFrame, let page { if let layoutFrame, let page {
snapshot = RDEPUBPageLayoutSnapshot.build(from: layoutFrame, page: page) snapshot = RDEPUBPageLayoutSnapshot.build(
from: layoutFrame,
page: page,
contentOffsetBase: contentOffsetBase
)
} else { } else {
snapshot = nil snapshot = nil
} }
@ -47,7 +53,11 @@ final class RDEPUBPageInteractionController {
) )
let idx = dtLine.stringIndex(forPosition: relativePoint) let idx = dtLine.stringIndex(forPosition: relativePoint)
guard idx != NSNotFound, idx >= 0 else { return nil } 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 #else
return nil return nil
#endif #endif
@ -75,9 +85,9 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue } 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 endIdx = overlap.location + overlap.length
let endX = dtLine.offset(forStringIndex: endIdx) let endX = dtLine.offset(forStringIndex: localIndex(for: endIdx))
#else #else
let startX: CGFloat = 0 let startX: CGFloat = 0
let endX: CGFloat = line.frame.width let endX: CGFloat = line.frame.width
@ -135,7 +145,7 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } 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 #else
let offsetX: CGFloat = 0 let offsetX: CGFloat = 0
#endif #endif
@ -187,10 +197,18 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? { private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
guard let dtLayoutFrame else { return nil } guard let dtLayoutFrame else { return nil }
return dtLayoutFrame.lineContaining(UInt(range.location)) return dtLayoutFrame.lineContaining(UInt(max(localIndex(for: range.location), 0)))
} }
#endif #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] { private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
guard rects.count > 1 else { return rects } guard rects.count > 1 else { return rects }

View File

@ -130,7 +130,8 @@ struct RDEPUBPageLayoutSnapshot {
#if canImport(DTCoreText) #if canImport(DTCoreText)
static func build( static func build(
from layoutFrame: DTCoreTextLayoutFrame, from layoutFrame: DTCoreTextLayoutFrame,
page: RDEPUBTextPage page: RDEPUBTextPage,
contentOffsetBase: Int
) -> RDEPUBPageLayoutSnapshot? { ) -> RDEPUBPageLayoutSnapshot? {
guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else { guard let dtLines = layoutFrame.lines as? [DTCoreTextLayoutLine], !dtLines.isEmpty else {
return nil return nil
@ -139,7 +140,7 @@ struct RDEPUBPageLayoutSnapshot {
var lines: [RDEPUBPageLine] = [] var lines: [RDEPUBPageLine] = []
var runs: [RDEPUBPageRun] = [] var runs: [RDEPUBPageRun] = []
var attachments: [RDEPUBPageAttachment] = [] var attachments: [RDEPUBPageAttachment] = []
let pageOffset = page.pageStartOffset let pageOffset = contentOffsetBase
for dtLine in dtLines { for dtLine in dtLines {
let lineRange = offset(dtLine.stringRange(), by: pageOffset) let lineRange = offset(dtLine.stringRange(), by: pageOffset)

View File

@ -114,6 +114,7 @@ final class RDEPUBTextContentView: UIView {
private var coreTextDisplayContent: NSAttributedString? private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange? private var coreTextDisplayRange: NSRange?
private var coreTextDisplayOffsetBase: Int = 0
#endif #endif
private let interactionController = RDEPUBPageInteractionController() private let interactionController = RDEPUBPageInteractionController()
@ -256,7 +257,7 @@ final class RDEPUBTextContentView: UIView {
coverImageView.isHidden = true coverImageView.isHidden = true
coverImageView.image = nil coverImageView.image = nil
let selectionContent = NSMutableAttributedString(attributedString: page.content) let selectionContent = normalizedPageContent(from: page)
let selectionRange = NSRange(location: 0, length: selectionContent.length) let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute( selectionContent.addAttribute(
.foregroundColor, .foregroundColor,
@ -265,7 +266,10 @@ final class RDEPUBTextContentView: UIView {
) )
#if canImport(DTCoreText) #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) let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute( displayContent.addAttribute(
.foregroundColor, .foregroundColor,
@ -275,7 +279,8 @@ final class RDEPUBTextContentView: UIView {
coreTextContentView.isHidden = false coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear coreTextContentView.backgroundColor = .clear
coreTextDisplayContent = displayContent 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.isHidden = true
textView.isUserInteractionEnabled = false textView.isUserInteractionEnabled = false
textView.attributedText = nil textView.attributedText = nil
@ -646,6 +651,47 @@ final class RDEPUBTextContentView: UIView {
return proxy 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) #if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() { private func updateCoreTextLayoutFrameIfNeeded() {
guard !coreTextContentView.isHidden, guard !coreTextContentView.isHidden,
@ -666,7 +712,11 @@ final class RDEPUBTextContentView: UIView {
layouter.shouldCacheLayoutFrames = false layouter.shouldCacheLayoutFrames = false
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange) let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
coreTextContentView.layoutFrame = layoutFrame coreTextContentView.layoutFrame = layoutFrame
interactionController.configure(layoutFrame: layoutFrame, page: page) interactionController.configure(
layoutFrame: layoutFrame,
page: page,
contentOffsetBase: coreTextDisplayOffsetBase
)
overlayView.updateSnapshot(interactionController.snapshot) overlayView.updateSnapshot(interactionController.snapshot)
backgroundOverlayView.updateSnapshot(interactionController.snapshot) backgroundOverlayView.updateSnapshot(interactionController.snapshot)
} }

View File

@ -624,19 +624,31 @@ public class RDReaderView: UIView {
/// ///
/// ///
private func pageViewForDisplay(pageNum: Int) -> UIView { private func pageViewForDisplay(pageNum: Int) -> UIView {
if let cached = pageCurlCachedViews[pageNum] { let reusableView = detachedReusablePageView(for: pageNum)
return cached let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: reusableView)
} ?? reusableView
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) { ?? UIView()
preloaded.removeFromSuperview()
pageCurlCachedViews[pageNum] = preloaded
return preloaded
}
let view = dataSource?.pageContentView(readerView: self, pageNum: pageNum, containerView: nil) ?? UIView()
pageCurlCachedViews[pageNum] = view pageCurlCachedViews[pageNum] = view
return 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<Int>) { private func trimCachedPageViews(keeping pageNumbers: Set<Int>) {
@ -705,7 +717,7 @@ public class RDReaderView: UIView {
preloadHostView.frame = bounds preloadHostView.frame = bounds
for targetPage in targets { 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() let contentView = dataSource?.pageContentView(readerView: self, pageNum: targetPage, containerView: existing) ?? existing ?? UIView()
preloadedPageViews[targetPage] = contentView preloadedPageViews[targetPage] = contentView
if contentView.superview !== preloadHostView { if contentView.superview !== preloadHostView {