Files

937 lines
44 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}
}