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.. 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) } } } }