fix(reader): stabilize pagination display and page curl reuse
This commit is contained in:
parent
83b705b9ae
commit
736902b489
@ -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 }
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user