feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
+936
View File
@@ -0,0 +1,936 @@
import SwiftUI
import UIKit
private final class ReaderLayoutTextView: UITextView {
var onLayout: ((UITextView) -> Void)?
private var reportedSize = CGSize.zero
private var reportedContentSize = CGSize.zero
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0,
bounds.size != reportedSize || contentSize != reportedContentSize else { return }
reportedSize = bounds.size
reportedContentSize = contentSize
onLayout?(self)
}
}
struct ReaderTextView: UIViewRepresentable {
let text: String
let title: String
let previousTitle: String?
let previousText: String
let isPreviousReady: Bool
let continuationTitle: String?
let continuationText: String
let isContinuationReady: Bool
let isVolume: Bool
let fontSize: Double
let fontPath: String
let letterSpacing: Double
let lineSpacing: Double
let paragraphSpacing: Double
let paragraphIndent: String
let paddingLeft: Double
let paddingRight: Double
let paddingTop: Double
let paddingBottom: Double
let headerInset: Double
let footerInset: Double
let fontWeight: Int
let titleMode: Int
let titleSize: Double
let titleTopSpacing: Double
let titleBottomSpacing: Double
let underlineMode: Int
let isSelectable: Bool
let fullJustify: Bool
let pageAnimation: Int
let isEInkMode: Bool
let clickActions: [Int]
let backgroundColor: UIColor
let foregroundColor: UIColor
let requestedPosition: Int
let jumpPosition: Int?
let renderRevision: Int
@Binding var readerPosition: Int
@Binding var pageRanges: [NSRange]
@Binding var currentPage: Int
@Binding var totalPages: Int
let isAutoReading: Bool
let isAutoReadPaused: Bool
let isLoadingNextChapter: Bool
let secondsPerScreen: Double
let onReachedEnd: (Int) -> Void
let onReachedStart: (Int) -> Void
let onEnteredPrevious: (Int) -> Void
let onEnteredContinuation: (Int) -> Void
let onDeferredRender: () -> Void
let onCancelNextLoading: () -> Void
let onPageChanged: () -> Void
let onTapAction: (Int) -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIView(context: Context) -> UITextView {
// Pagination uses NSLayoutManager. Start in TextKit 1 rather than
// switching a TextKit 2 view after its content size has been computed.
let view: ReaderLayoutTextView
if #available(iOS 16.0, *) {
view = ReaderLayoutTextView(usingTextLayoutManager: false)
} else {
view = ReaderLayoutTextView(frame: .zero)
}
view.onLayout = { [weak coordinator = context.coordinator] view in
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
view.accessibilityIdentifier = "reader.text"
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
view.backgroundColor = backgroundColor
view.isOpaque = backgroundColor != .clear
view.delegate = context.coordinator
view.isEditable = false
view.isSelectable = false
view.alwaysBounceVertical = effectivePageAnimation == 3
view.isScrollEnabled = true
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
view.textContainer.lineFragmentPadding = 4
view.showsVerticalScrollIndicator = false
view.isPagingEnabled = effectivePageAnimation != 3
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
context.coordinator.installDisplayLink(for: view)
context.coordinator.installGestures(on: view)
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
return view
}
func updateUIView(_ view: UITextView, context: Context) {
context.coordinator.parent = self
let viewportSizeChanged = view.bounds.size != .zero
&& context.coordinator.lastViewportSize != view.bounds.size
if viewportSizeChanged {
context.coordinator.lastViewportSize = view.bounds.size
}
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
view.isSelectable = false
view.alwaysBounceVertical = effectivePageAnimation == 3
view.isScrollEnabled = true
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
view.showsVerticalScrollIndicator = false
view.isPagingEnabled = effectivePageAnimation != 3
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
view.backgroundColor = backgroundColor
view.isOpaque = backgroundColor != .clear
view.isSelectable = false
view.selectedRange = NSRange(location: 0, length: 0)
view.resignFirstResponder()
view.textContainerInset = .init(
top: CGFloat(paddingTop + headerInset),
left: CGFloat(paddingLeft),
bottom: CGFloat(paddingBottom + footerInset),
right: CGFloat(paddingRight)
)
let contentKey = text.hashValue
let primaryContentKey = "\(title)|\(text)".hashValue
let showsContinuation = effectivePageAnimation == 3 && isContinuationReady && continuationTitle != nil
let showsPrevious = showsContinuation && isPreviousReady && previousTitle != nil
let adjacentContentKey = "\(showsPrevious)|\(previousTitle ?? "")|\(previousText)|\(showsContinuation)|\(continuationTitle ?? "")|\(continuationText)".hashValue
let displaysTitle = titleMode != 2 || isVolume
let titlePrefix = displaysTitle && !title.isEmpty ? title + "\n" : ""
let styleKey = "\(contentKey)|\(adjacentContentKey)|\(title)|\(isVolume)|\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(headerInset)|\(footerInset)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(isSelectable)|\(fullJustify)|\(effectivePageAnimation)|\(foregroundColor.hash)"
if context.coordinator.styleKey != styleKey {
if context.coordinator.shouldDeferRender(in: view) {
context.coordinator.needsDeferredRender = true
return
}
let primaryContentChanged = context.coordinator.primaryContentKey != primaryContentKey
let visiblePosition = context.coordinator.contentCharacter(at: view.contentOffset, in: view)
context.coordinator.resetAutoReadForPageChange()
context.coordinator.resetPageRangeRetry()
context.coordinator.contentKey = contentKey
context.coordinator.primaryContentKey = primaryContentKey
context.coordinator.styleKey = styleKey
let typography = ReaderTypography(configuration: self)
let bodyAttributes = typography.bodyAttributes
let rendered = NSMutableAttributedString(string: "")
context.coordinator.previousBodyStart = nil
context.coordinator.previousBodyEnd = nil
context.coordinator.continuationSectionStart = nil
context.coordinator.continuationBodyStart = nil
if showsPrevious {
let previousHeading = displaysTitle ? (previousTitle ?? "") + "\n" : ""
rendered.append(NSAttributedString(string: previousHeading, attributes: typography.headingAttributes))
context.coordinator.previousBodyStart = rendered.length
rendered.append(NSAttributedString(
string: previousText.isEmpty ? String(repeating: "\n", count: 80) : previousText,
attributes: bodyAttributes
))
context.coordinator.previousBodyEnd = rendered.length
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
}
context.coordinator.currentSectionStart = rendered.length
if !titlePrefix.isEmpty {
rendered.append(NSAttributedString(string: titlePrefix, attributes: typography.headingAttributes))
}
context.coordinator.renderedPrefixLength = rendered.length
rendered.append(NSAttributedString(string: text, attributes: bodyAttributes))
context.coordinator.currentBodyEnd = rendered.length
if showsContinuation {
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
context.coordinator.continuationSectionStart = rendered.length
let continuationHeading = displaysTitle ? (continuationTitle ?? "") + "\n" : ""
rendered.append(NSAttributedString(string: continuationHeading, attributes: typography.headingAttributes))
context.coordinator.continuationBodyStart = rendered.length
rendered.append(NSAttributedString(
string: continuationText.isEmpty ? String(repeating: "\n", count: 80) : continuationText,
attributes: bodyAttributes
))
}
context.coordinator.isApplyingContent = true
view.attributedText = rendered
context.coordinator.isApplyingContent = false
context.coordinator.scroll(
to: primaryContentChanged ? requestedPosition : visiblePosition,
in: view
)
let generation = context.coordinator.layoutGeneration
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
guard coordinator?.layoutGeneration == generation else { return }
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
} else if viewportSizeChanged {
context.coordinator.scroll(to: readerPosition, in: view)
let generation = context.coordinator.layoutGeneration
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
guard coordinator?.layoutGeneration == generation else { return }
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
}
if let jumpPosition, context.coordinator.lastJumpPosition != jumpPosition {
context.coordinator.lastJumpPosition = jumpPosition
context.coordinator.scroll(to: jumpPosition, in: view)
} else if jumpPosition == nil {
context.coordinator.lastJumpPosition = nil
}
context.coordinator.updateLoadingFooter(in: view, visible: isLoadingNextChapter)
context.coordinator.updateAutoRead(
isRunning: isAutoReading,
isPaused: isAutoReadPaused,
secondsPerScreen: secondsPerScreen
)
// SwiftUI can reset the bindings after the chapter's TextKit layout has
// already completed. Reconcile them even when the text/style is unchanged.
if view.bounds.width > 0, view.bounds.height > 0 {
context.coordinator.reportPage(in: view)
if pageRanges.isEmpty { context.coordinator.reportPageRanges(in: view) }
}
}
static func dismantleUIView(_ view: UITextView, coordinator: Coordinator) {
coordinator.invalidate()
}
final class Coordinator: NSObject, UITextViewDelegate, UIGestureRecognizerDelegate {
var parent: ReaderTextView
var contentKey = 0
var primaryContentKey = 0
var styleKey = ""
var renderedPrefixLength = 0
var currentSectionStart = 0
var currentBodyEnd = 0
var previousBodyStart: Int?
var previousBodyEnd: Int?
var continuationSectionStart: Int?
var continuationBodyStart: Int?
var lastJumpPosition: Int?
private(set) var layoutGeneration = 0
var lastViewportSize = CGSize.zero
private var isAutoReading = false
private var isAutoReadPaused = false
private var secondsPerScreen = 10.0
private weak var textView: UITextView?
private var displayLink: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var isUserDragging = false
private var lastDragOffsetY: CGFloat = 0
private var dragDirection = 0
private var didReachEnd = false
private var didReachStart = false
private var didEnterPrevious = false
private var didEnterContinuation = false
private var pendingPreviousPosition: Int?
private var pendingContinuationPosition: Int?
var needsDeferredRender = false
var isApplyingContent = false
private var isTurningPage = false
private var isRenderingAutoPage = false
private var lastReportedPage: Int?
private var autoPageProgress: CGFloat = 0
private var eInkElapsed: CFTimeInterval = 0
private var eInkPageInterval: CFTimeInterval = 10
private var autoPageTarget: CGFloat?
private weak var autoPageOverlay: UIImageView?
private var pageRangeRetryCount = 0
private weak var nextSwipe: UISwipeGestureRecognizer?
private weak var previousSwipe: UISwipeGestureRecognizer?
private weak var readerTap: UITapGestureRecognizer?
private weak var loadingFooter: UIView?
private var didRequestLoadingCancellation = false
init(parent: ReaderTextView) { self.parent = parent }
func installDisplayLink(for view: UITextView) {
textView = view
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
link.add(to: .main, forMode: .common)
displayLink = link
}
func installGestures(on view: UITextView) {
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
tap.delaysTouchesBegan = false
tap.cancelsTouchesInView = false
tap.delegate = self
view.addGestureRecognizer(tap)
readerTap = tap
let next = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
next.direction = .left
view.addGestureRecognizer(next)
let previous = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
previous.direction = .right
view.addGestureRecognizer(previous)
nextSwipe = next
previousSwipe = previous
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === readerTap || otherGestureRecognizer === readerTap
}
func updateInteractionMode(on view: UITextView, isScrollMode: Bool) {
// The scroll reader owns the vertical pan gesture. Horizontal
// swipe recognizers must be disabled there or UIKit can cancel the
// pan before the user reaches the end of a long chapter.
nextSwipe?.isEnabled = !isScrollMode
previousSwipe?.isEnabled = !isScrollMode
view.panGestureRecognizer.isEnabled = isScrollMode
view.alwaysBounceVertical = isScrollMode
view.isPagingEnabled = !isScrollMode
view.showsVerticalScrollIndicator = false
}
func updateLoadingFooter(in view: UITextView, visible: Bool) {
if !visible {
loadingFooter?.removeFromSuperview()
loadingFooter = nil
view.contentInset.bottom = 0
didRequestLoadingCancellation = false
return
}
let footer: UIView
if let loadingFooter {
footer = loadingFooter
} else {
let container = UIView()
container.isUserInteractionEnabled = false
let indicator = UIActivityIndicatorView(style: .medium)
indicator.color = parent.foregroundColor.withAlphaComponent(0.65)
indicator.startAnimating()
let label = UILabel()
label.text = "正在加载下一章…"
label.textColor = parent.foregroundColor.withAlphaComponent(0.65)
label.font = .systemFont(ofSize: 13)
let stack = UIStackView(arrangedSubviews: [indicator, label])
stack.axis = .horizontal
stack.spacing = 8
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor)
])
view.addSubview(container)
loadingFooter = container
footer = container
}
view.contentInset.bottom = 64
footer.frame = CGRect(x: 0, y: view.contentSize.height, width: view.bounds.width, height: 52)
}
func shouldDeferRender(in view: UITextView) -> Bool {
parent.pageAnimation == 3 && (isUserDragging || view.isDragging || view.isDecelerating)
}
func invalidate() {
layoutGeneration &+= 1
resetAutoPage()
displayLink?.invalidate()
displayLink = nil
}
func updateAutoRead(isRunning: Bool, isPaused: Bool, secondsPerScreen: Double) {
let wasRunning = isAutoReading
let wasPaused = isAutoReadPaused
self.isAutoReading = isRunning
isAutoReadPaused = isPaused
self.secondsPerScreen = max(1, secondsPerScreen)
if wasRunning && !isRunning {
resetAutoPage()
} else if !wasRunning && isRunning {
didReachEnd = false
lastTimestamp = 0
eInkPageInterval = self.secondsPerScreen
} else if isPaused || wasPaused != isPaused {
lastTimestamp = 0
}
}
func resetAutoReadForPageChange() {
didReachEnd = false
didReachStart = false
lastReportedPage = nil
didEnterPrevious = false
didEnterContinuation = false
pendingPreviousPosition = nil
pendingContinuationPosition = nil
resetAutoPage()
}
func scroll(to characterPosition: Int, in view: UITextView) {
guard !view.text.isEmpty else { return }
layoutGeneration &+= 1
let generation = layoutGeneration
let prefix = characterPosition == 0 && parent.pageAnimation == 3
? currentSectionStart
: renderedPrefixLength
let location = ReaderPositionResolver.renderedLocation(
position: characterPosition,
prefixLength: prefix,
renderedLength: view.text.utf16.count
)
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
view.layoutManager.ensureLayout(for: view.textContainer)
let glyph = view.layoutManager.glyphIndexForCharacter(at: location)
var point = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: glyph, length: 1),
in: view.textContainer
).origin
point.y += view.textContainerInset.top
let limits = self.scrollLimits(for: view)
let target = min(
max(point.y - view.adjustedContentInset.top, limits.minimum),
limits.maximum
)
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
}
private func readingViewportHeight(for view: UITextView) -> CGFloat {
max(
view.bounds.height
- view.adjustedContentInset.top
- view.adjustedContentInset.bottom
- view.textContainerInset.top
- view.textContainerInset.bottom,
1
)
}
private func scrollLimits(for view: UITextView) -> (minimum: CGFloat, maximum: CGFloat) {
let minimum = -view.adjustedContentInset.top
let maximum = max(
view.contentSize.height - view.bounds.height + view.adjustedContentInset.bottom,
minimum
)
return (minimum, maximum)
}
private func pageMetrics(for view: UITextView) -> (current: Int, total: Int) {
let pageHeight = readingViewportHeight(for: view)
if parent.pageAnimation == 3, currentBodyEnd > currentSectionStart {
view.layoutManager.ensureLayout(for: view.textContainer)
let glyphCount = view.layoutManager.numberOfGlyphs
if glyphCount > 0 {
let first = min(view.layoutManager.glyphIndexForCharacter(at: currentSectionStart), glyphCount - 1)
let last = min(view.layoutManager.glyphIndexForCharacter(at: currentBodyEnd - 1), glyphCount - 1)
let start = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: first, length: 1), in: view.textContainer
).minY + view.textContainerInset.top - view.adjustedContentInset.top
let end = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: last, length: 1), in: view.textContainer
).maxY + view.textContainerInset.top - view.adjustedContentInset.top
let total = max(Int(ceil((end - start) / pageHeight)), 1)
let position = max(view.contentOffset.y - start, 0)
return (min(Int(floor(position / pageHeight)) + 1, total), total)
}
}
let limits = scrollLimits(for: view)
let scrollRange = max(limits.maximum - limits.minimum, 0)
let total = max(Int(ceil(scrollRange / pageHeight)) + 1, 1)
guard total > 1 else { return (1, 1) }
let position = min(max(view.contentOffset.y - limits.minimum, 0), scrollRange)
let current = position >= scrollRange - 0.5
? total
: min(Int(floor(position / pageHeight)) + 1, total)
return (current, total)
}
private func renderedCharacter(at offset: CGPoint, in view: UITextView) -> Int {
guard !view.text.isEmpty else { return 0 }
let point = CGPoint(
x: view.textContainerInset.left + 1,
y: offset.y + view.textContainerInset.top + 1
)
let glyphCount = view.layoutManager.numberOfGlyphs
guard glyphCount > 0 else { return 0 }
let glyph = min(view.layoutManager.glyphIndex(for: point, in: view.textContainer), glyphCount - 1)
let character = view.layoutManager.characterIndexForGlyph(at: glyph)
return min(max(character, 0), view.text.utf16.count)
}
func contentCharacter(at offset: CGPoint, in view: UITextView) -> Int {
let character = renderedCharacter(at: offset, in: view)
return min(max(character - renderedPrefixLength, 0), parent.text.utf16.count)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !isRenderingAutoPage, !isApplyingContent else { return }
guard let view = scrollView as? UITextView, !view.text.isEmpty else { return }
if parent.isLoadingNextChapter, !didRequestLoadingCancellation {
let textBottom = max(
view.contentSize.height - view.bounds.height
+ view.adjustedContentInset.bottom - view.contentInset.bottom,
-view.adjustedContentInset.top
)
if view.contentOffset.y < textBottom - 44 {
didRequestLoadingCancellation = true
DispatchQueue.main.async { [weak self] in self?.parent.onCancelNextLoading() }
}
}
if isUserDragging {
let delta = scrollView.contentOffset.y - lastDragOffsetY
if abs(delta) > 0.5 { dragDirection = delta > 0 ? 1 : -1 }
lastDragOffsetY = scrollView.contentOffset.y
}
let metrics = pageMetrics(for: view)
reportPage(in: view)
if let lastReportedPage, lastReportedPage != metrics.current {
self.lastReportedPage = metrics.current
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onPageChanged()
}
} else if lastReportedPage == nil {
lastReportedPage = metrics.current
}
let contentCharacter = contentCharacter(at: scrollView.contentOffset, in: view)
let renderedCharacter = renderedCharacter(at: scrollView.contentOffset, in: view)
if let previousBodyStart,
let previousBodyEnd,
renderedCharacter < currentSectionStart {
pendingContinuationPosition = nil
let position = min(max(renderedCharacter - previousBodyStart, 0), max(previousBodyEnd - previousBodyStart - 1, 0))
if isUserDragging || view.isDecelerating {
pendingPreviousPosition = position
return
}
guard !didEnterPrevious else { return }
didEnterPrevious = true
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredPrevious(position)
}
return
}
if let continuationSectionStart,
let continuationBodyStart,
renderedCharacter >= continuationSectionStart {
pendingPreviousPosition = nil
let position = max(renderedCharacter - continuationBodyStart, 0)
if isUserDragging || view.isDecelerating {
pendingContinuationPosition = position
return
}
guard !didEnterContinuation else { return }
didEnterContinuation = true
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredContinuation(position)
}
return
}
pendingPreviousPosition = nil
pendingContinuationPosition = nil
guard contentCharacter != parent.readerPosition else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.readerPosition = contentCharacter
}
}
func reportPage(in view: UITextView) {
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
let metrics = pageMetrics(for: view)
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("-uiPagingDiagnostics") {
view.accessibilityLabel = "bounds=\(view.bounds.size) content=\(view.contentSize) used=\(view.layoutManager.usedRect(for: view.textContainer)) mode=\(parent.pageAnimation) pages=\(metrics.total)"
}
#endif
guard metrics.current != parent.currentPage || metrics.total != parent.totalPages else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.currentPage = metrics.current
self.parent.totalPages = metrics.total
}
}
func reportPageRanges(in view: UITextView) {
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
if parent.pageAnimation == 3 {
let ranges = parent.text.isEmpty
? []
: [NSRange(location: 0, length: parent.text.utf16.count)]
guard ranges != parent.pageRanges else { return }
DispatchQueue.main.async { [weak self] in self?.parent.pageRanges = ranges }
return
}
guard !view.text.isEmpty else { return }
view.layoutManager.ensureLayout(for: view.textContainer)
let pageHeight = readingViewportHeight(for: view)
let limits = scrollLimits(for: view)
let metrics = pageMetrics(for: view)
var ranges: [NSRange] = []
for page in 0..<metrics.total {
let offset = page == metrics.total - 1
? limits.maximum
: min(limits.minimum + CGFloat(page) * pageHeight, limits.maximum)
let rect = CGRect(
x: 0,
y: max(0, offset + view.textContainerInset.top),
width: max(view.textContainer.size.width, 1),
height: pageHeight
)
let glyphRange = view.layoutManager.glyphRange(
forBoundingRect: rect,
in: view.textContainer
)
guard glyphRange.length > 0 else { continue }
let characterRange = view.layoutManager.characterRange(
forGlyphRange: glyphRange,
actualGlyphRange: nil
)
let start = max(characterRange.location - renderedPrefixLength, 0)
let end = min(
characterRange.location + characterRange.length - renderedPrefixLength,
parent.text.utf16.count
)
guard end > start else { continue }
let range = NSRange(location: start, length: end - start)
if ranges.last?.location == range.location,
ranges.last?.length == range.length { continue }
ranges.append(range)
}
if ranges.isEmpty {
guard pageRangeRetryCount < 10 else { return }
pageRangeRetryCount += 1
let generation = layoutGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.reportPageRanges(in: view)
}
return
}
pageRangeRetryCount = 0
guard ranges != parent.pageRanges else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.pageRanges = ranges
}
}
func resetPageRangeRetry() {
pageRangeRetryCount = 0
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isUserDragging = true
lastDragOffsetY = scrollView.contentOffset.y
dragDirection = 0
didReachEnd = false
didReachStart = false
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
triggerChapterBoundaryIfNeeded(in: view)
}
if !decelerate {
isUserDragging = false
flushPendingChapterTransition()
requestDeferredRenderIfNeeded()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
triggerChapterBoundaryIfNeeded(in: view)
}
isUserDragging = false
flushPendingChapterTransition()
requestDeferredRenderIfNeeded()
}
private func requestDeferredRenderIfNeeded() {
guard needsDeferredRender else { return }
needsDeferredRender = false
DispatchQueue.main.async { [weak self] in self?.parent.onDeferredRender() }
}
private func flushPendingChapterTransition() {
let previous = pendingPreviousPosition
let continuation = pendingContinuationPosition
pendingPreviousPosition = nil
pendingContinuationPosition = nil
let generation = layoutGeneration
if let previous, !didEnterPrevious {
didEnterPrevious = true
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredPrevious(previous)
}
} else if let continuation, !didEnterContinuation {
didEnterContinuation = true
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredContinuation(continuation)
}
}
}
private func triggerChapterBoundaryIfNeeded(in view: UITextView) {
let (minimum, maximum) = scrollLimits(for: view)
let atEnd = view.contentOffset.y >= maximum - 0.5
let atStart = view.contentOffset.y <= minimum + 0.5
if atEnd, dragDirection > 0, continuationSectionStart == nil, !didReachEnd {
didReachEnd = true
UIImpactFeedbackGenerator(style: .light).impactOccurred()
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
} else if atStart, dragDirection < 0, previousBodyStart == nil, !didReachStart {
didReachStart = true
UIImpactFeedbackGenerator(style: .light).impactOccurred()
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
}
}
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
guard let view = textView, gesture.state == .ended else { return }
let point = gesture.location(in: view)
// UIScrollView changes its bounds origin as content scrolls, and
// gesture locations use that content coordinate space. Convert
// back to viewport coordinates before dividing the visible screen
// into the 3×3 tap grid.
let viewportX = point.x - view.bounds.minX
let viewportY = point.y - view.bounds.minY
let column = min(max(Int(viewportX / max(view.bounds.width / 3, 1)), 0), 2)
let row = min(max(Int(viewportY / max(view.bounds.height / 3, 1)), 0), 2)
let index = row * 3 + column
let action = index == 4
? 0
: (parent.clickActions.indices.contains(index) ? parent.clickActions[index] : 0)
if parent.pageAnimation == 3, action == 1 || action == 2 {
return
}
switch action {
case 1: turnPage(1)
case 2: turnPage(-1)
case -1: break
default: parent.onTapAction(action)
}
}
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
turnPage(gesture.direction == .left ? 1 : -1)
}
private func turnPage(_ direction: Int) {
guard !isTurningPage, let view = textView else { return }
if isAutoReading { resetAutoReadForPageChange() }
let pageHeight = readingViewportHeight(for: view)
let (minimum, maximum) = scrollLimits(for: view)
let target = min(max(view.contentOffset.y + CGFloat(direction) * pageHeight, minimum), maximum)
if direction < 0, view.contentOffset.y <= minimum + 0.5 {
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
return
}
if direction > 0, view.contentOffset.y >= maximum - 0.5 {
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
isTurningPage = true
let completion: (Bool) -> Void = { [weak self] _ in self?.isTurningPage = false }
switch parent.isEInkMode ? 4 : parent.pageAnimation {
case 4:
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
isTurningPage = false
case 0:
let transition = CATransition()
transition.duration = 0.3
transition.type = direction > 0 ? .reveal : .moveIn
transition.subtype = direction > 0 ? .fromRight : .fromLeft
view.layer.add(transition, forKey: "reader-cover")
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
case 1:
let transition = CATransition()
transition.duration = 0.3
transition.type = .push
transition.subtype = direction > 0 ? .fromRight : .fromLeft
view.layer.add(transition, forKey: "reader-slide")
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
case 2:
UIView.transition(
with: view,
duration: 0.38,
options: direction > 0 ? .transitionCurlUp : .transitionCurlDown,
animations: { view.setContentOffset(CGPoint(x: 0, y: target), animated: false) },
completion: completion
)
default:
view.setContentOffset(CGPoint(x: 0, y: target), animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { [weak self] in self?.isTurningPage = false }
}
}
private func resetAutoPage() {
autoPageProgress = 0
eInkElapsed = 0
eInkPageInterval = secondsPerScreen
autoPageTarget = nil
autoPageOverlay?.removeFromSuperview()
autoPageOverlay = nil
lastTimestamp = 0
}
private func prepareAutoPage(in view: UITextView) -> Bool {
let pageHeight = readingViewportHeight(for: view)
let maximum = scrollLimits(for: view).maximum
guard view.contentOffset.y < maximum - 0.5 else { return false }
let target = min(view.contentOffset.y + pageHeight, maximum)
let originalOffset = view.contentOffset
isRenderingAutoPage = true
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
view.layoutIfNeeded()
let renderer = UIGraphicsImageRenderer(bounds: view.bounds)
let image = renderer.image { context in view.layer.render(in: context.cgContext) }
view.setContentOffset(originalOffset, animated: false)
isRenderingAutoPage = false
let overlay = UIImageView(image: image)
overlay.isUserInteractionEnabled = false
overlay.contentMode = .top
overlay.clipsToBounds = true
overlay.layer.borderColor = view.tintColor.cgColor
overlay.layer.borderWidth = 0
overlay.frame = CGRect(x: 0, y: originalOffset.y, width: view.bounds.width, height: 0)
view.addSubview(overlay)
autoPageOverlay = overlay
autoPageTarget = target
return true
}
private func advancePagedAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
if autoPageOverlay == nil, !prepareAutoPage(in: view) {
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
didReachEnd = false
let pageHeight = readingViewportHeight(for: view)
autoPageProgress += pageHeight / CGFloat(secondsPerScreen) * CGFloat(elapsed)
let revealedHeight = min(autoPageProgress, pageHeight)
if let overlay = autoPageOverlay {
overlay.frame = CGRect(
x: 0,
y: view.contentOffset.y,
width: view.bounds.width,
height: revealedHeight
)
overlay.layer.borderWidth = revealedHeight > 0 ? 0.5 : 0
}
guard autoPageProgress >= pageHeight, let target = autoPageTarget else { return }
autoPageOverlay?.removeFromSuperview()
autoPageOverlay = nil
autoPageTarget = nil
autoPageProgress = 0
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
private func advanceEInkAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
eInkElapsed += elapsed
guard eInkElapsed >= eInkPageInterval else { return }
eInkElapsed = 0
eInkPageInterval = secondsPerScreen
let pageHeight = readingViewportHeight(for: view)
let maximum = scrollLimits(for: view).maximum
guard view.contentOffset.y < maximum - 0.5 else {
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
didReachEnd = false
let target = min(view.contentOffset.y + pageHeight, maximum)
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
@objc private func step(_ link: CADisplayLink) {
guard isAutoReading, !isAutoReadPaused, !isUserDragging, let view = textView else {
lastTimestamp = link.timestamp
return
}
guard lastTimestamp > 0 else {
lastTimestamp = link.timestamp
return
}
let elapsed = link.timestamp - lastTimestamp
lastTimestamp = link.timestamp
if parent.isEInkMode {
advanceEInkAutoRead(in: view, elapsed: elapsed)
return
}
if parent.pageAnimation != 3 {
advancePagedAutoRead(in: view, elapsed: elapsed)
return
}
let maximum = scrollLimits(for: view).maximum
let next = view.contentOffset.y
+ readingViewportHeight(for: view) / CGFloat(secondsPerScreen) * CGFloat(elapsed)
if next >= maximum - 0.5 {
view.setContentOffset(CGPoint(x: 0, y: maximum), animated: false)
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
} else {
didReachEnd = false
view.setContentOffset(CGPoint(x: 0, y: next), animated: false)
}
}
}
}