mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
580 lines
28 KiB
Swift
580 lines
28 KiB
Swift
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)"
|
|
}
|
|
}
|