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