import SwiftUI import UIKit struct ReaderScrollView: UIViewRepresentable { let configuration: ReaderTextView let chapters: [ReaderScrollChapter] let resetID: Int let activeIndex: Int let loadingAhead: Bool let loadingBehind: Bool let onNeedChapters: (Int) -> Void let onPositionChanged: (Int, Int) -> Void let onRetainChapters: (Set) -> Void func makeCoordinator() -> Coordinator { Coordinator(parent: self) } func makeUIView(context: Context) -> ReaderScrollCollectionView { context.coordinator.makeView() } func updateUIView(_ view: ReaderScrollCollectionView, context: Context) { context.coordinator.parent = self context.coordinator.update(view) } static func dismantleUIView(_ view: ReaderScrollCollectionView, coordinator: Coordinator) { coordinator.displayLink?.invalidate() view.viewportChanged = nil } @MainActor final class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate, UIGestureRecognizerDelegate { var parent: ReaderScrollView private(set) var blocks: [ReaderScrollBlock] = [] private var chapterLayouts: [Int: ReaderScrollChapterLayout] = [:] private weak var view: ReaderScrollCollectionView? var displayLink: CADisplayLink? private var lastTimestamp: CFTimeInterval = 0 private var styleKey = "" private var viewportHeight: CGFloat = 0 private var revision: Int? private var restored = false private var jumpKey: String? private var reporting = false private var applying = false private var requestedAhead: Int? private var requestedBehind: Int? private var wasLoadingAhead = false private var wasLoadingBehind = false private var lastPageKey = "" private var reachedEnd = false #if DEBUG private var decelerationInsertions = 0 private var continuedDecelerations = 0 private var interruptedDecelerations = 0 private var awaitingDecelerationOffset: CGFloat? #endif init(parent: ReaderScrollView) { self.parent = parent } func makeView() -> ReaderScrollCollectionView { let collection = ReaderScrollCollectionView(frame: .zero, collectionViewLayout: ReaderContinuousLayout()) collection.backgroundColor = parent.configuration.backgroundColor collection.contentInsetAdjustmentBehavior = .never collection.alwaysBounceVertical = true collection.showsVerticalScrollIndicator = false collection.decelerationRate = .normal collection.dataSource = self collection.delegate = self collection.accessibilityIdentifier = "reader.continuous" collection.register(ReaderScrollCell.self, forCellWithReuseIdentifier: "text") let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:))) tap.delegate = self collection.addGestureRecognizer(tap) collection.viewportChanged = { [weak self, weak collection] in guard let self, let collection else { return } self.update(collection) } let link = CADisplayLink(target: self, selector: #selector(step(_:))) link.add(to: .main, forMode: .common) displayLink = link view = collection return collection } func update(_ view: ReaderScrollCollectionView) { guard !applying, view.bounds.width > 0, view.bounds.height > 0 else { return } applying = true defer { applying = false } let config = parent.configuration if wasLoadingAhead, !parent.loadingAhead { requestedAhead = nil } if wasLoadingBehind, !parent.loadingBehind { requestedBehind = nil } wasLoadingAhead = parent.loadingAhead wasLoadingBehind = parent.loadingBehind view.backgroundColor = config.backgroundColor view.loadingLabel.textColor = config.foregroundColor.withAlphaComponent(0.6) view.loadingLabel.text = parent.chapters.isEmpty ? "正在加载正文…" : (parent.loadingAhead || parent.loadingBehind ? "正在加载后续章节…" : nil) view.positionLoadingLabel() let reset = revision != parent.resetID if reset { revision = parent.resetID restored = false jumpKey = nil requestedAhead = nil requestedBehind = nil lastPageKey = "" chapterLayouts = [:] } let anchor = reset ? nil : captureAnchor() let newStyleKey = config.scrollTypographyKey + "|\(view.bounds.width)" let styleChanged = styleKey != newStyleKey let contentChanged = styleChanged || viewportHeight != view.bounds.height || parent.chapters.count != chapterLayouts.count || parent.chapters.contains { chapterLayouts[$0.id]?.chapter != $0 } if contentChanged || reset { let oldIDs = blocks.map(\.id) var layouts: [Int: ReaderScrollChapterLayout] = [:] for chapter in parent.chapters { if !styleChanged, let cached = chapterLayouts[chapter.id], cached.chapter == chapter { layouts[chapter.id] = cached } else { layouts[chapter.id] = ReaderScrollChapterLayout( chapter: chapter, configuration: config, width: view.bounds.width ) } } let newBlocks = parent.chapters.flatMap { layouts[$0.id]?.blocks ?? [] } let layout = view.collectionViewLayout as! ReaderContinuousLayout let geometry = ReaderContinuousLayout.geometry( blocks: newBlocks, width: view.bounds.width, top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), viewportHeight: view.bounds.height ) let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) } let correction = targetY.map { $0 - view.contentOffset.y } ?? 0 let newIDs = newBlocks.map(\.id) let oldSet = Set(oldIDs), newSet = Set(newIDs) let deletions = oldIDs.indices.filter { !newSet.contains(oldIDs[$0]) }.map { IndexPath(item: $0, section: 0) } let insertions = newIDs.indices.filter { !oldSet.contains(newIDs[$0]) }.map { IndexPath(item: $0, section: 0) } if !deletions.isEmpty { requestedAhead = nil; requestedBehind = nil } chapterLayouts = layouts styleKey = newStyleKey viewportHeight = view.bounds.height #if DEBUG let wasDecelerating = view.isDecelerating #endif UIView.performWithoutAnimation { view.performBatchUpdates { self.blocks = newBlocks view.deleteItems(at: deletions) view.insertItems(at: insertions) // UIKit applies the geometry and anchor compensation together. // Setting contentOffset here would cancel an in-flight deceleration. layout.install(frames: geometry.frames, size: geometry.size, correction: correction) } view.layoutIfNeeded() } #if DEBUG if wasDecelerating, !insertions.isEmpty { decelerationInsertions += 1 if view.isDecelerating { awaitingDecelerationOffset = view.contentOffset.y } else { interruptedDecelerations += 1 } } #endif for cell in view.visibleCells { guard let cell = cell as? ReaderScrollCell, let index = view.indexPath(for: cell)?.item, blocks.indices.contains(index) else { continue } cell.canvas.block = blocks[index] } reachedEnd = false } if !restored, let chapter = chapterLayouts[parent.activeIndex] { restored = true seek(chapter: chapter.chapter.id, position: config.requestedPosition, includeHeading: config.requestedPosition == 0) } if let position = config.jumpPosition { let key = "\(parent.activeIndex):\(position)" if jumpKey != key { jumpKey = key seek(chapter: parent.activeIndex, position: position, includeHeading: position == 0) } } else { jumpKey = nil } scheduleReport() } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { blocks.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "text", for: indexPath) as! ReaderScrollCell cell.canvas.block = blocks[indexPath.item] return cell } func scrollViewDidScroll(_ scrollView: UIScrollView) { guard !applying else { return } #if DEBUG if let offset = awaitingDecelerationOffset, scrollView.isDecelerating, abs(scrollView.contentOffset.y - offset) > 0.5 { continuedDecelerations += 1 awaitingDecelerationOffset = nil } #endif view?.positionLoadingLabel() scheduleReport() } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { scheduleReport() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scheduleReport() } private func scheduleReport() { guard !reporting else { return } reporting = true let scheduledRevision = parent.resetID DispatchQueue.main.async { [weak self] in guard let self else { return } self.reporting = false guard self.parent.resetID == scheduledRevision else { self.scheduleReport(); return } self.reportViewport() } } private func reportViewport() { guard let view, restored, let anchor = captureAnchor(), let chapter = chapterLayouts[anchor.chapter] else { return } parent.onPositionChanged(anchor.chapter, anchor.position) let config = parent.configuration let chapterRows = blocks.indices.filter { blocks[$0].chapter.chapter.id == anchor.chapter } let layout = view.collectionViewLayout as! ReaderContinuousLayout if let first = chapterRows.first, let last = chapterRows.last { let start = layout.frames[first].minY, end = layout.frames[last].maxY let height = max(view.bounds.height, 1) let total = max(Int(ceil((end - start) / height)), 1) let page = min(max(Int(floor((view.contentOffset.y - start) / height)) + 1, 1), total) if config.currentPage != page { config.currentPage = page } if config.totalPages != total { config.totalPages = total } let ranges = chapter.chapter.content.isEmpty ? [] : [NSRange(location: 0, length: chapter.chapter.content.utf16.count)] if config.pageRanges != ranges { config.pageRanges = ranges } let key = "\(anchor.chapter):\(page)" if lastPageKey != key { lastPageKey = key; config.onPageChanged() } } let runway = view.bounds.height * 3 if let last = parent.chapters.last, layout.collectionViewContentSize.height - view.bounds.maxY < runway, requestedAhead != last.id { requestedAhead = last.id parent.onNeedChapters(1) } if let first = parent.chapters.first, view.contentOffset.y < runway, requestedBehind != first.id { requestedBehind = first.id parent.onNeedChapters(-1) } if !view.isDragging, !view.isDecelerating, parent.chapters.count > 12 { let retainedRect = view.bounds.insetBy(dx: 0, dy: -view.bounds.height * 6) let visible = blocks.indices.filter { layout.frames[$0].intersects(retainedRect) } let indices = Set(visible.map { blocks[$0].chapter.chapter.id }) let ordered = parent.chapters.map(\.id) if let lo = ordered.firstIndex(where: { indices.contains($0) }), let hi = ordered.lastIndex(where: { indices.contains($0) }) { let keep = Set(ordered[max(lo - 2, 0)...min(hi + 2, ordered.count - 1)]) if keep.count < ordered.count { parent.onRetainChapters(keep) } } } #if DEBUG view.accessibilityValue = "chapter=\(anchor.chapter) position=\(anchor.position) chapters=\(parent.chapters.count) decelInsertions=\(decelerationInsertions) continued=\(continuedDecelerations) interrupted=\(interruptedDecelerations)" #endif } private struct Anchor { let chapter: Int let position: Int let inHeading: Bool let screenY: CGFloat } private func captureAnchor() -> Anchor? { guard let view, !blocks.isEmpty else { return nil } let layout = view.collectionViewLayout as! ReaderContinuousLayout let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0) guard let row = layout.row(at: y) else { return nil } let block = blocks[row], chapter = block.chapter let localY = y - layout.frames[row].minY + block.top let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container) let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0)) let character = chapter.manager.characterIndexForGlyph(at: safeGlyph) let lineY = chapter.manager.lineFragmentRect(forGlyphAt: safeGlyph, effectiveRange: nil).minY return Anchor( chapter: chapter.chapter.id, position: min(max(character - chapter.bodyStart, 0), chapter.chapter.content.utf16.count), inHeading: character < chapter.bodyStart, screenY: layout.frames[row].minY + lineY - block.top - view.contentOffset.y ) } private func offset(for anchor: Anchor, blocks: [ReaderScrollBlock], frames: [CGRect]) -> CGFloat? { guard let chapter = blocks.first(where: { $0.chapter.chapter.id == anchor.chapter })?.chapter else { return nil } let character = anchor.inHeading ? 0 : ReaderPositionResolver.renderedLocation( position: anchor.position, prefixLength: chapter.bodyStart, renderedLength: chapter.storage.length ) let glyph = chapter.manager.glyphIndexForCharacter(at: character) guard let row = blocks.firstIndex(where: { $0.chapter.chapter.id == anchor.chapter && NSLocationInRange(glyph, $0.glyphRange) }) else { return nil } let lineY = chapter.manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY return frames[row].minY + lineY - blocks[row].top - anchor.screenY } private func seek(chapter: Int, position: Int, includeHeading: Bool) { guard let view else { return } let layout = view.collectionViewLayout as! ReaderContinuousLayout let anchor = Anchor(chapter: chapter, position: max(position, 0), inHeading: includeHeading, screenY: 0) guard let y = offset(for: anchor, blocks: blocks, frames: layout.frames) else { return } let maximum = max(layout.collectionViewContentSize.height - view.bounds.height, 0) view.setContentOffset(CGPoint(x: 0, y: min(max(y, 0), maximum)), animated: false) } @objc private func tapped(_ gesture: UITapGestureRecognizer) { guard let view, !view.isDragging, !view.isDecelerating else { return } let point = gesture.location(in: view) if let row = (view.collectionViewLayout as? ReaderContinuousLayout)?.row(at: point.y), blocks[row].chapter.chapter.error != nil { parent.configuration.onTapAction(0) return } let x = point.x - view.bounds.minX, y = point.y - view.bounds.minY let column = min(max(Int(x / max(view.bounds.width / 3, 1)), 0), 2) let row = min(max(Int(y / max(view.bounds.height / 3, 1)), 0), 2) let index = row * 3 + column let actions = parent.configuration.clickActions let action = index == 4 ? 0 : (actions.indices.contains(index) ? actions[index] : 0) if action != 1, action != 2, action != -1 { parent.configuration.onTapAction(action) } } @objc private func step(_ link: CADisplayLink) { defer { lastTimestamp = link.timestamp } guard let view, lastTimestamp > 0, parent.configuration.isAutoReading, !parent.configuration.isAutoReadPaused, !view.isDragging, !view.isDecelerating else { return } let elapsed = min(link.timestamp - lastTimestamp, 0.1) let maximum = max(view.contentSize.height - view.bounds.height, 0) let next = min(view.contentOffset.y + view.bounds.height / CGFloat(max(parent.configuration.secondsPerScreen, 1)) * elapsed, maximum) if next > view.contentOffset.y { view.setContentOffset(CGPoint(x: 0, y: next), animated: false) } if next >= maximum - 0.5, !reachedEnd { reachedEnd = true parent.configuration.onReachedEnd(parent.configuration.readerPosition) } } } } @MainActor final class ReaderScrollCollectionView: UICollectionView { var viewportChanged: (() -> Void)? let loadingLabel = UILabel() private var viewportSize = CGSize.zero override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { super.init(frame: frame, collectionViewLayout: layout) loadingLabel.font = .systemFont(ofSize: 12) loadingLabel.textAlignment = .center loadingLabel.isUserInteractionEnabled = false addSubview(loadingLabel) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() positionLoadingLabel() if viewportSize != bounds.size { viewportSize = bounds.size viewportChanged?() } } func positionLoadingLabel() { loadingLabel.frame = CGRect(x: 0, y: bounds.maxY - 28, width: bounds.width, height: 24) bringSubviewToFront(loadingLabel) } } @MainActor final class ReaderContinuousLayout: UICollectionViewLayout { private(set) var frames: [CGRect] = [] private var size = CGSize.zero override var collectionViewContentSize: CGSize { size } static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) { var y = top let frames = blocks.map { block -> CGRect in let frame = CGRect(x: 0, y: y, width: width, height: block.height) y += block.height return frame } let finalChapter = blocks.last?.chapter.chapter.id let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter }) .map { frames[$0].minY } ?? top return (frames, CGSize(width: width, height: max(y, finalStart + viewportHeight) + bottom)) } func install(frames: [CGRect], size: CGSize, correction: CGFloat) { let context = UICollectionViewLayoutInvalidationContext() context.contentOffsetAdjustment = CGPoint(x: 0, y: correction) context.contentSizeAdjustment = CGSize(width: size.width - self.size.width, height: size.height - self.size.height) self.frames = frames self.size = size invalidateLayout(with: context) } func row(at y: CGFloat) -> Int? { guard !frames.isEmpty else { return nil } var low = 0, high = frames.count while low < high { let middle = (low + high) / 2 if frames[middle].maxY <= y { low = middle + 1 } else { high = middle } } return min(low, frames.count - 1) } override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { guard let start = row(at: rect.minY) else { return [] } var result: [UICollectionViewLayoutAttributes] = [] for row in start.. rect.maxY { break } if let attributes = layoutAttributesForItem(at: IndexPath(item: row, section: 0)) { result.append(attributes) } } return result } override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { guard frames.indices.contains(indexPath.item) else { return nil } let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath) attributes.frame = frames[indexPath.item] return attributes } } @MainActor struct ReaderScrollBlock { struct ID: Hashable { let chapter: Int; let firstGlyph: Int } let chapter: ReaderScrollChapterLayout let glyphRange: NSRange let top: CGFloat let height: CGFloat var id: ID { .init(chapter: chapter.chapter.id, firstGlyph: glyphRange.location) } } @MainActor final class ReaderScrollChapterLayout { let chapter: ReaderScrollChapter let storage: NSTextStorage let manager = NSLayoutManager() let container: NSTextContainer let bodyStart: Int let left: CGFloat private var fragments: [(range: NSRange, top: CGFloat, height: CGFloat)] = [] var blocks: [ReaderScrollBlock] { fragments.map { .init(chapter: self, glyphRange: $0.range, top: $0.top, height: $0.height) } } init(chapter: ReaderScrollChapter, configuration: ReaderTextView, width: CGFloat) { self.chapter = chapter let typography = ReaderTypography(configuration: configuration) left = CGFloat(configuration.paddingLeft) + 4 let textWidth = max(width - left - CGFloat(configuration.paddingRight) - 4, 1) container = NSTextContainer(size: CGSize(width: textWidth, height: .greatestFiniteMagnitude)) container.lineFragmentPadding = 0 let attributed = NSMutableAttributedString(string: "") if configuration.titleMode != 2, !chapter.title.isEmpty { attributed.append(NSAttributedString(string: chapter.title + "\n", attributes: typography.headingAttributes)) } bodyStart = attributed.length let body = chapter.error.map { "本章加载失败\n\n\($0)\n\n轻点打开阅读菜单。\n" } ?? (chapter.content.isEmpty ? "本章暂无正文\n" : chapter.content) attributed.append(NSAttributedString(string: body, attributes: typography.bodyAttributes)) storage = NSTextStorage(attributedString: attributed) storage.addLayoutManager(manager) manager.addTextContainer(container) manager.ensureLayout(for: container) var startGlyph = 0 var top: CGFloat = 0 let count = manager.numberOfGlyphs manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: count)) { rect, _, _, range, _ in if rect.minY - top >= 1000, range.location > startGlyph { self.fragments.append((NSRange(location: startGlyph, length: range.location - startGlyph), top, rect.minY - top)) startGlyph = range.location top = rect.minY } } let bottom = manager.usedRect(for: container).maxY + CGFloat(configuration.fontSize * 1.4) fragments.append((NSRange(location: startGlyph, length: count - startGlyph), top, max(bottom - top, 1))) } } @MainActor private final class ReaderScrollCell: UICollectionViewCell { let canvas = ReaderScrollCanvas() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear contentView.backgroundColor = .clear contentView.addSubview(canvas) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews(); canvas.frame = contentView.bounds } override func prepareForReuse() { super.prepareForReuse(); canvas.block = nil } } @MainActor private final class ReaderScrollCanvas: UIView { var block: ReaderScrollBlock? { didSet { if let block { let range = block.chapter.manager.characterRange(forGlyphRange: block.glyphRange, actualGlyphRange: nil) accessibilityLabel = block.chapter.storage.attributedSubstring(from: range).string } else { accessibilityLabel = nil } setNeedsDisplay() } } override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false isAccessibilityElement = true accessibilityTraits = .staticText contentMode = .redraw } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draw(_ rect: CGRect) { guard let block else { return } let origin = CGPoint(x: block.chapter.left, y: -block.top) block.chapter.manager.drawBackground(forGlyphRange: block.glyphRange, at: origin) block.chapter.manager.drawGlyphs(forGlyphRange: block.glyphRange, at: origin) } } @MainActor struct ReaderTypography { let bodyAttributes: [NSAttributedString.Key: Any] let headingAttributes: [NSAttributedString.Key: Any] init(configuration c: ReaderTextView) { let weight: UIFont.Weight = switch c.fontWeight { case 1: .bold; case 2: .light; default: .regular } let font = ReaderFontStore.font(at: c.fontPath, size: CGFloat(c.fontSize), weight: c.fontWeight) ?? (c.fontWeight == 0 ? (UIFont(name: "STSongti-SC-Regular", size: CGFloat(c.fontSize)) ?? UIFont.systemFont(ofSize: CGFloat(c.fontSize))) : UIFont.systemFont(ofSize: CGFloat(c.fontSize), weight: weight)) let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = CGFloat(c.lineSpacing) paragraph.paragraphSpacing = CGFloat(c.paragraphSpacing) paragraph.firstLineHeadIndent = (c.paragraphIndent as NSString).size(withAttributes: [ .font: font, .kern: CGFloat(c.letterSpacing * c.fontSize) ]).width paragraph.alignment = c.fullJustify ? .justified : .natural var body: [NSAttributedString.Key: Any] = [ .font: font, .kern: CGFloat(c.letterSpacing * c.fontSize), .foregroundColor: c.foregroundColor, .paragraphStyle: paragraph ] if c.underlineMode != 0 { var underline = NSUnderlineStyle.single if c.underlineMode == 2 { underline.insert(.patternDash) } body[.underlineStyle] = underline.rawValue body[.underlineColor] = c.foregroundColor } bodyAttributes = body let headingFont = ReaderFontStore.font(at: c.fontPath, size: CGFloat(max(5, c.fontSize + c.titleSize)), weight: c.fontWeight) ?? UIFont.systemFont(ofSize: CGFloat(max(5, c.fontSize + c.titleSize)), weight: weight) let heading = NSMutableParagraphStyle() heading.alignment = c.titleMode == 1 ? .center : .left heading.paragraphSpacingBefore = CGFloat(c.titleTopSpacing) heading.paragraphSpacing = CGFloat(c.titleBottomSpacing) headingAttributes = [.font: headingFont, .foregroundColor: c.foregroundColor, .paragraphStyle: heading] } } extension ReaderTextView { var scrollTypographyKey: String { "\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(fullJustify)|\(foregroundColor.hash)" } }