Phase 1: Context 拆分 - 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices - RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services Phase 2: Runtime 拆分 - 新增 RDEPUBPresentationRuntime 处理分页状态管理 - 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排 - RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade Phase 0.5: 性能优化 - prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false) - extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载 - RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争 - CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded) - RDEPUBTextPageRenderView 引入静态位图缓存 - RDEPUBTextContentView 新增 loadingSpinner 占位页 Phase 3: 状态机 - 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验) - 新增 RDEPUBPaginationState 记录分页来源 Review 修复 - makeSummary 重复方法合并 - ensureNavigationTargetAvailable 同步路径加注释标记 UI 测试 - 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景) - 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
279 lines
8.8 KiB
Swift
279 lines
8.8 KiB
Swift
import UIKit
|
|
|
|
#if canImport(DTCoreText)
|
|
import DTCoreText
|
|
|
|
final class RDEPUBTextPageRenderView: UIView {
|
|
|
|
enum SelectionHandle {
|
|
case start
|
|
case end
|
|
}
|
|
|
|
var layoutFrame: DTCoreTextLayoutFrame? {
|
|
didSet {
|
|
invalidateStaticContent()
|
|
}
|
|
}
|
|
|
|
var drawOptions: DTCoreTextLayoutFrameDrawingOptions = DTCoreTextLayoutFrameDrawingOptions(rawValue: 1)! {
|
|
didSet {
|
|
invalidateStaticContent()
|
|
}
|
|
}
|
|
|
|
var attributedDisplayContent: NSAttributedString? {
|
|
didSet {
|
|
invalidateStaticContent()
|
|
}
|
|
}
|
|
|
|
var selectionRects: [CGRect] = [] {
|
|
didSet {
|
|
setNeedsDisplay()
|
|
}
|
|
}
|
|
|
|
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24)
|
|
|
|
private let selectionHandleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1)
|
|
|
|
private let selectionHandleStemWidth: CGFloat = 2.5
|
|
|
|
private let selectionHandleKnobRadius: CGFloat = 7
|
|
|
|
private let selectionHandleHitSlop: CGFloat = 20
|
|
|
|
private var cachedStaticImage: UIImage?
|
|
|
|
private var cachedStaticBoundsSize: CGSize = .zero
|
|
|
|
private var needsStaticContentRedraw = true
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = .clear
|
|
isOpaque = false
|
|
contentMode = .redraw
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func draw(_ rect: CGRect) {
|
|
guard let context = UIGraphicsGetCurrentContext(),
|
|
let layoutFrame else { return }
|
|
|
|
context.saveGState()
|
|
|
|
if cachedStaticImage == nil
|
|
|| cachedStaticBoundsSize != bounds.size
|
|
|| needsStaticContentRedraw {
|
|
cachedStaticImage = renderStaticImage(layoutFrame: layoutFrame)
|
|
cachedStaticBoundsSize = bounds.size
|
|
needsStaticContentRedraw = false
|
|
}
|
|
|
|
if let cachedStaticImage {
|
|
cachedStaticImage.draw(in: bounds)
|
|
} else {
|
|
if let attributedDisplayContent {
|
|
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
|
}
|
|
layoutFrame.draw(in: context, options: drawOptions)
|
|
}
|
|
|
|
drawSelection(in: context)
|
|
|
|
context.restoreGState()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
if cachedStaticBoundsSize != bounds.size {
|
|
invalidateStaticContent()
|
|
}
|
|
}
|
|
|
|
private func drawHighlights(
|
|
in context: CGContext,
|
|
attributedString: NSAttributedString,
|
|
layoutFrame: DTCoreTextLayoutFrame
|
|
) {
|
|
let fullRange = NSRange(location: 0, length: attributedString.length)
|
|
|
|
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
|
guard let color = value as? UIColor else { return }
|
|
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
|
color.setFill()
|
|
for rect in rects {
|
|
context.fill(rect)
|
|
}
|
|
}
|
|
|
|
attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in
|
|
guard let color = value as? UIColor else { return }
|
|
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
|
color.setStroke()
|
|
context.setLineWidth(2)
|
|
for rect in rects {
|
|
let y = rect.maxY - 1
|
|
context.move(to: CGPoint(x: rect.minX, y: y))
|
|
context.addLine(to: CGPoint(x: rect.maxX, y: y))
|
|
context.strokePath()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] {
|
|
var rects: [CGRect] = []
|
|
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects }
|
|
|
|
for line in lines {
|
|
let lineRange = line.stringRange()
|
|
let overlap = NSIntersectionRange(range, lineRange)
|
|
guard overlap.length > 0 else { continue }
|
|
|
|
let startX = line.offset(forStringIndex: overlap.location)
|
|
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
|
|
let rect = CGRect(
|
|
x: line.baselineOrigin.x + startX,
|
|
y: line.baselineOrigin.y - line.ascent,
|
|
width: endX - startX,
|
|
height: line.ascent + line.descent
|
|
)
|
|
rects.append(rect)
|
|
}
|
|
|
|
return rects
|
|
}
|
|
|
|
private func drawSelection(in context: CGContext) {
|
|
guard !selectionRects.isEmpty else { return }
|
|
selectionColor.setFill()
|
|
for rect in selectionRects {
|
|
context.fill(rect)
|
|
}
|
|
drawSelectionHandles(in: context)
|
|
}
|
|
|
|
func selectionHandle(at point: CGPoint) -> SelectionHandle? {
|
|
guard let handleGeometry = selectionHandleGeometry else { return nil }
|
|
|
|
let startDistance = point.distance(to: handleGeometry.startKnobCenter)
|
|
let endDistance = point.distance(to: handleGeometry.endKnobCenter)
|
|
let maxDistance = selectionHandleKnobRadius + selectionHandleHitSlop
|
|
|
|
let startMatched = startDistance <= maxDistance
|
|
let endMatched = endDistance <= maxDistance
|
|
|
|
switch (startMatched, endMatched) {
|
|
case (true, true):
|
|
return startDistance <= endDistance ? .start : .end
|
|
case (true, false):
|
|
return .start
|
|
case (false, true):
|
|
return .end
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func selectionContains(_ point: CGPoint) -> Bool {
|
|
selectionRects.contains { rect in
|
|
rect.insetBy(dx: -6, dy: -8).contains(point)
|
|
}
|
|
}
|
|
|
|
private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? {
|
|
guard let firstRect = selectionRects.first,
|
|
let lastRect = selectionRects.last else {
|
|
return nil
|
|
}
|
|
|
|
let startKnobCenter = CGPoint(
|
|
x: firstRect.minX,
|
|
y: firstRect.minY - selectionHandleKnobRadius
|
|
)
|
|
let endKnobCenter = CGPoint(
|
|
x: lastRect.maxX,
|
|
y: lastRect.maxY + selectionHandleKnobRadius
|
|
)
|
|
|
|
return (startKnobCenter: startKnobCenter, endKnobCenter: endKnobCenter)
|
|
}
|
|
|
|
private func drawSelectionHandles(in context: CGContext) {
|
|
guard let firstRect = selectionRects.first,
|
|
let lastRect = selectionRects.last else {
|
|
return
|
|
}
|
|
|
|
context.saveGState()
|
|
context.setFillColor(selectionHandleColor.cgColor)
|
|
|
|
let stemHalfWidth = selectionHandleStemWidth / 2
|
|
|
|
let startStem = CGRect(
|
|
x: firstRect.minX - stemHalfWidth,
|
|
y: firstRect.minY - selectionHandleKnobRadius * 2,
|
|
width: selectionHandleStemWidth,
|
|
height: firstRect.height + selectionHandleKnobRadius * 2
|
|
)
|
|
context.fill(startStem)
|
|
let startKnob = CGRect(
|
|
x: firstRect.minX - selectionHandleKnobRadius,
|
|
y: firstRect.minY - selectionHandleKnobRadius * 2,
|
|
width: selectionHandleKnobRadius * 2,
|
|
height: selectionHandleKnobRadius * 2
|
|
)
|
|
context.fillEllipse(in: startKnob)
|
|
|
|
let endStem = CGRect(
|
|
x: lastRect.maxX - stemHalfWidth,
|
|
y: lastRect.minY,
|
|
width: selectionHandleStemWidth,
|
|
height: lastRect.height + selectionHandleKnobRadius * 2
|
|
)
|
|
context.fill(endStem)
|
|
let endKnob = CGRect(
|
|
x: lastRect.maxX - selectionHandleKnobRadius,
|
|
y: lastRect.maxY,
|
|
width: selectionHandleKnobRadius * 2,
|
|
height: selectionHandleKnobRadius * 2
|
|
)
|
|
context.fillEllipse(in: endKnob)
|
|
|
|
context.restoreGState()
|
|
}
|
|
|
|
private func invalidateStaticContent() {
|
|
cachedStaticImage = nil
|
|
needsStaticContentRedraw = true
|
|
setNeedsDisplay()
|
|
}
|
|
|
|
private func renderStaticImage(layoutFrame: DTCoreTextLayoutFrame) -> UIImage? {
|
|
guard bounds.width > 0, bounds.height > 0 else { return nil }
|
|
let format = UIGraphicsImageRendererFormat.default()
|
|
format.opaque = false
|
|
let renderer = UIGraphicsImageRenderer(size: bounds.size, format: format)
|
|
return renderer.image { _ in
|
|
guard let staticContext = UIGraphicsGetCurrentContext() else { return }
|
|
if let attributedDisplayContent {
|
|
drawHighlights(in: staticContext, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
|
}
|
|
layoutFrame.draw(in: staticContext, options: drawOptions)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension CGPoint {
|
|
|
|
func distance(to point: CGPoint) -> CGFloat {
|
|
hypot(x - point.x, y - point.y)
|
|
}
|
|
}
|
|
#endif
|