mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 20:31:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
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<Int>) -> 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..<frames.count {
|
||||
if frames[row].minY > 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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user