Files
ReadViewSDK/Sources/RDEpubReaderView/EPUBUI/RDEPUBReaderController.swift
T
shenleiandClaude Opus 5 529663829f 修复退出 EPUB 阅读器后宿主侧滑返回手势被改成阅读器的配置
阅读器在 viewWillAppear 中无条件把导航控制器的 interactivePopGestureRecognizer
接管为自己的 delegate 并强制开启,退出时却不做任何还原。宿主如果原本关闭了
侧滑返回,或为它设置过自定义 delegate,从阅读器返回后这些配置就被永久改写,
其它页面的返回手势行为随之变化。阅读器自身接管横向翻页,进入期间开启侧滑返回
也会与翻页手势争抢边缘触摸。

改为进入时记录原有的开关状态与 delegate 并直接关闭该手势,退出时按记录值还原。
记录只在首次 viewWillAppear 时进行,避免阅读器内部多次前后台切换把自己写入的
状态当成宿主原值。

该修复原本以本地补丁形式存在于 readoor 项目的 Vendor 副本中,现回流上游,避免
后续同步 SDK 时反复丢失。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:03:43 +09:00

517 lines
18 KiB
Swift
Raw 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 UIKit
public final class RDEPUBReaderController: UIViewController {
public weak var delegate: RDEPUBReaderDelegate?
public var configuration: RDEPUBReaderConfiguration {
didSet {
// M-09: Apply all configuration side effects even before view is loaded,
// but UI-dependent actions only after view is loaded.
readerContext.configuration = configuration
guard isViewLoaded else { return }
applyWebViewDebugPolicy()
persistReaderSettingsIfNeeded()
let oldConfiguration = oldValue
applyReaderViewConfiguration()
if isExternalTextBook {
if requiresRepagination(from: oldConfiguration, to: configuration) {
rebuildExternalTextBook()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
return
}
guard publication != nil else { return }
if requiresRepagination(from: oldConfiguration, to: configuration) {
repaginatePreservingCurrentLocation()
} else if requiresVisibleRefresh(from: oldConfiguration, to: configuration) {
refreshVisibleContentPreservingLocation()
}
}
}
public var currentLocation: RDEPUBLocation? {
currentVisibleLocation()
}
public var currentPageNumber: Int? {
guard readerView.currentPage >= 0 else { return nil }
return readerView.currentPage + 1
}
public internal(set) var currentSelection: RDEPUBSelection? {
get { readerContext.currentSelection }
set { readerContext.currentSelection = newValue }
}
public var highlights: [RDEPUBHighlight] {
activeHighlights
}
public var bookmarks: [RDEPUBBookmark] {
activeBookmarks
}
public var annotations: [RDEPUBAnnotation] {
let merged = activeHighlights.map(\.annotation) + activeBookmarks.map(\.annotation)
return merged.sorted { $0.createdAt < $1.createdAt }
}
public var tableOfContents: [EPUBTableOfContentsItem] {
publication?.tableOfContents ?? []
}
public var flattenedTableOfContents: [RDEPUBReaderTableOfContentsItem] {
guard let publication else { return [] }
return flattenedTableOfContentsItems(from: publication.tableOfContents)
}
public var currentTableOfContentsItem: RDEPUBReaderTableOfContentsItem? {
resolvedCurrentTableOfContentsItem()
}
let epubURL: URL
let persistence: RDEPUBReaderPersistence?
let dependencies: RDEPUBReaderDependencies
let readerView = RDEpubReaderView()
let loadingIndicator: UIActivityIndicatorView = {
UIActivityIndicatorView(style: .large)
}()
let errorLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
label.textAlignment = .center
label.textColor = .darkGray
label.isHidden = true
return label
}()
let paginationHostView = UIView()
/// Chapter-level shared display content/layouter for text pages
/// (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1/P1-3).
let textDisplayCache = RDEPUBChapterDisplayContentCache()
lazy var readerContext = RDEPUBReaderContext(controller: self)
var parser: RDEPUBParser? {
get { readerContext.parser }
set { readerContext.parser = newValue }
}
var publication: RDEPUBPublication? {
get { readerContext.publication }
set { readerContext.publication = newValue }
}
var readingSession: RDEPUBReadingSession? {
get { readerContext.readingSession }
set { readerContext.readingSession = newValue }
}
var textBook: RDEPUBTextBook? {
get { readerContext.textBook }
set { readerContext.textBook = newValue }
}
var activePages: [EPUBPage] {
readerContext.activePages
}
var activeChapters: [EPUBChapterInfo] {
readerContext.activeChapters
}
var activeBookmarks: [RDEPUBBookmark] {
get { readerContext.activeBookmarks }
set { readerContext.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] {
get { readerContext.activeHighlights }
set { readerContext.activeHighlights = newValue }
}
var transientHighlights: [RDEPUBHighlight] {
get { readerContext.transientHighlights }
set { readerContext.transientHighlights = newValue }
}
lazy var topToolView = runtime.makeTopToolView()
lazy var bottomToolView = runtime.makeBottomToolView()
lazy var searchBarView = RDEPUBReaderSearchBarView()
/// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial
var cachedTrialWallView: UIView?
/// 阅读器进入前宿主导航栏的真实状态。详情页使用自定义导航栏时系统栏原本
/// 就是隐藏的,退出阅读器必须恢复该状态,不能无条件显示系统导航栏。
private var navigationBarHiddenBeforeReader: Bool?
/// 阅读器进入前宿主侧滑返回手势的真实状态。阅读器自己接管横向翻页,
/// 期间必须关闭该手势;退出时按原值恢复,不能把宿主的配置改成阅读器的。
private var interactivePopWasEnabledBeforeReader: Bool?
private weak var interactivePopDelegateBeforeReader: UIGestureRecognizerDelegate?
private(set) var isSearchBarVisible = false
var currentBookIdentifier: String? {
get { readerContext.currentBookIdentifier }
set { readerContext.currentBookIdentifier = newValue }
}
var textBookCache: RDEPUBTextBookCache { readerContext.textBookCache }
var currentBrightness: CGFloat {
get { readerContext.currentBrightness }
set { readerContext.currentBrightness = newValue }
}
var didStartInitialLoad: Bool {
get { readerContext.didStartInitialLoad }
set { readerContext.didStartInitialLoad = newValue }
}
var isRepaginating: Bool {
get { readerContext.isRepaginating }
set { readerContext.isRepaginating = newValue }
}
var lastTextPaginationPageSize: CGSize? {
get { readerContext.lastTextPaginationPageSize }
set { readerContext.lastTextPaginationPageSize = newValue }
}
var isReconcilingTextPaginationSize = false
var paginationToken: UUID {
get { readerContext.paginationToken }
set { readerContext.paginationToken = newValue }
}
var searchState: RDEPUBSearchState? {
get { readerContext.searchState }
set { readerContext.searchState = newValue }
}
var isExternalTextBook: Bool {
get { readerContext.isExternalTextBook }
set { readerContext.isExternalTextBook = newValue }
}
var textFileURL: URL? {
get { readerContext.textFileURL }
set { readerContext.textFileURL = newValue }
}
lazy var readerAssemblyCoordinator = RDEPUBReaderAssemblyCoordinator(context: readerContext)
lazy var runtime = RDEPUBReaderRuntime(context: readerContext)
public init(
epubURL: URL,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
dependencies: RDEPUBReaderDependencies = .live
) {
let persistedSettings = persistence?.loadReaderSettings()
self.epubURL = epubURL
self.configuration = persistedSettings?.applying(to: configuration) ?? configuration
self.persistence = persistence
self.dependencies = dependencies
let brightness = max(0, min(1, persistedSettings?.brightness ?? dependencies.environment.currentBrightness))
super.init(nibName: nil, bundle: nil)
readerContext.dependencies = dependencies
readerContext.configuration = self.configuration
readerContext.epubURL = epubURL
readerContext.persistence = persistence
self.currentBrightness = brightness
applyWebViewDebugPolicy()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func applyWebViewDebugPolicy() {
RDEPUBWebViewDebug.applyDebugPolicy(
inspectableEnabled: configuration.allowsInspectableWebViews,
verboseLoggingEnabled: configuration.enablesVerboseWebViewLogging
)
}
public convenience init(
textBook: RDEPUBTextBook,
bookIdentifier: String,
title: String,
textFileURL: URL? = nil,
configuration: RDEPUBReaderConfiguration = .default,
persistence: RDEPUBReaderPersistence? = RDEPUBUserDefaultsPersistence(),
dependencies: RDEPUBReaderDependencies = .live
) {
let persistedSettings = persistence?.loadReaderSettings()
let resolvedConfig = persistedSettings?.applying(to: configuration) ?? configuration
self.init(
epubURL: URL(string: "about:blank")!,
configuration: resolvedConfig,
persistence: persistence,
dependencies: dependencies
)
self.isExternalTextBook = true
self.textBook = textBook
self.textFileURL = textFileURL
self.currentBookIdentifier = bookIdentifier
self.title = title
self.didStartInitialLoad = true
}
public override func viewDidLoad() {
super.viewDidLoad()
currentBrightness = currentBrightness
readerAssemblyCoordinator.assembleInterface()
readerAssemblyCoordinator.finishExternalTextBookLaunchIfNeeded()
readerView.onToolViewVisibilityChanged = { [weak self] isVisible in
self?.handleToolViewVisibilityChanged(isVisible: isVisible)
}
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if navigationBarHiddenBeforeReader == nil {
navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden
}
if interactivePopWasEnabledBeforeReader == nil {
interactivePopWasEnabledBeforeReader = navigationController?.interactivePopGestureRecognizer?.isEnabled
interactivePopDelegateBeforeReader = navigationController?.interactivePopGestureRecognizer?.delegate
}
navigationController?.setNavigationBarHidden(true, animated: animated)
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if let navigationBarHiddenBeforeReader {
navigationController?.setNavigationBarHidden(
navigationBarHiddenBeforeReader,
animated: animated
)
}
if let interactivePopWasEnabledBeforeReader {
navigationController?.interactivePopGestureRecognizer?.delegate = interactivePopDelegateBeforeReader
navigationController?.interactivePopGestureRecognizer?.isEnabled = interactivePopWasEnabledBeforeReader
}
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
startInitialLoadIfNeeded()
RDEPUBSettingsFlipAutomation.startIfNeeded(controller: self)
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
runtime.viewportMonitor.viewDidLayoutSubviews()
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
runtime.handleMemoryWarning()
textDisplayCache.removeAll()
}
public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
runtime.viewportMonitor.viewWillTransition(with: coordinator)
coordinator.animate(alongsideTransition: nil) { _ in
RDEPUBMemoryProbe.log("orientationTransition size=\(Int(size.width))x\(Int(size.height))")
}
}
func showSearchBar() {
guard !isSearchBarVisible else { return }
isSearchBarVisible = true
searchBarView.apply(theme: configuration.theme)
searchBarView.onSearchSubmit = { [weak self] keyword in
self?.searchBarView.showSearching()
self?.runtime.search(keyword: keyword)
self?.updateSearchCount()
}
searchBarView.onSearchTextChanged = { [weak self] keyword in
guard let self else { return }
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
if normalizedKeyword.isEmpty {
self.runtime.clearSearch()
} else {
self.searchBarView.showSearching()
self.runtime.search(keyword: normalizedKeyword)
}
self.updateSearchCount()
}
searchBarView.onSearchPrevious = { [weak self] in
_ = self?.runtime.searchPrevious()
self?.updateSearchCount()
}
searchBarView.onSearchNext = { [weak self] in
_ = self?.runtime.searchNext()
self?.updateSearchCount()
}
searchBarView.onSelectMatch = { [weak self] matchIndex in
guard let self else { return }
let didNavigate = self.runtime.selectSearchMatch(at: matchIndex)
self.updateSearchCount()
if didNavigate {
self.hideSearchBar(clearSearch: false)
}
}
searchBarView.onClose = { [weak self] in
self?.hideSearchBar(clearSearch: true)
}
if readerView.isShowToolView {
installSearchBarView()
}
}
private func installSearchBarView() {
guard searchBarView.superview == nil else { return }
searchBarView.apply(theme: configuration.theme)
readerView.addSubview(searchBarView)
readerView.searchBarView = searchBarView
NSLayoutConstraint.activate([
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
searchBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor)
])
searchBarView.alpha = 0
searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
UIView.animate(withDuration: 0.28) {
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
}
if let keyword = searchState?.keyword, !keyword.isEmpty {
searchBarView.restoreKeyword(keyword)
updateSearchCount()
} else {
searchBarView.showNoResults()
}
DispatchQueue.main.async { [weak self] in
self?.searchBarView.textField.becomeFirstResponder()
}
}
func hideSearchBar(clearSearch: Bool = false) {
guard isSearchBarVisible else { return }
isSearchBarVisible = false
searchBarView.textField.resignFirstResponder()
UIView.animate(withDuration: 0.25, animations: {
self.searchBarView.alpha = 0
self.searchBarView.presentedView.transform = CGAffineTransform(translationX: 0, y: 40)
}) { _ in
self.searchBarView.removeFromSuperview()
self.searchBarView.alpha = 1
self.searchBarView.presentedView.transform = .identity
self.readerView.searchBarView = nil
}
if clearSearch {
runtime.clearSearch()
}
}
func updateSearchCount() {
guard let searchState else {
searchBarView.showNoResults()
return
}
searchBarView.updateResults(
sections: searchResultSections(for: searchState),
keyword: searchState.keyword,
currentMatchIndex: searchState.currentMatchIndex
)
}
func handleToolViewVisibilityChanged(isVisible: Bool) {
if isVisible {
if isSearchBarVisible {
installSearchBarView()
} else if searchState != nil {
showSearchBar()
}
} else {
if isSearchBarVisible {
hideSearchBar(clearSearch: false)
}
}
}
}
private extension RDEPUBReaderController {
func searchResultSections(for searchState: RDEPUBSearchState) -> [RDEPUBReaderSearchSection] {
let groupedMatches = Dictionary(grouping: Array(searchState.matches.enumerated()), by: { entry in
searchSectionTitle(for: entry.element)
})
let orderedTitles = searchState.matches.reduce(into: [String]()) { titles, match in
let title = searchSectionTitle(for: match)
if titles.last != title, titles.contains(title) == false {
titles.append(title)
}
}
return orderedTitles.compactMap { title in
guard let matches = groupedMatches[title] else { return nil }
let items = matches.map { offset, match in
RDEPUBReaderSearchSection.Item(
matchIndex: offset,
previewText: match.previewText,
isCurrent: offset == searchState.currentMatchIndex
)
}
return RDEPUBReaderSearchSection(title: title, items: items)
}
}
func searchSectionTitle(for match: RDEPUBSearchMatch) -> String {
guard let publication else {
return match.href
}
let normalizedMatchHref = publication.resourceResolver.normalizedHref(match.href) ?? match.href
let tocItems = flattenedTableOfContentsItems(from: publication.tableOfContents, includePageNumbers: false)
if let tocItem = tocItems.last(where: {
let rawHref = $0.href.components(separatedBy: "#").first ?? $0.href
let normalizedItemHref = publication.resourceResolver.normalizedHref(rawHref) ?? rawHref
return normalizedItemHref == normalizedMatchHref
}) {
return tocItem.title
}
if let spineIndex = publication.resourceResolver.spineIndex(forNormalizedHref: normalizedMatchHref),
publication.spine.indices.contains(spineIndex) {
return publication.spine[spineIndex].title
}
return normalizedMatchHref
}
}