import UIKit // MARK: - 文件说明 /// EPUB 阅读器主控制器 /// EPUBUI 层的核心入口,提供开箱即用的完整阅读器体验 /// 调用方只需传入 EPUB 文件 URL 或已构建的 TextBook,即可呈现完整的阅读界面 /// /// **架构位置:** EPUBUI(最顶层)→ Core → Parser → Foundation /// /// **核心职责:** /// - EPUB 解析与分页(支持流式排版和固定布局) /// - 阅读位置的持久化与恢复 /// - 高亮、书签的增删改查 /// - 工具栏、目录、设置等 UI 面板的协调管理 /// - 搜索功能(全文搜索、前后跳转) /// /// **打开流程:** init(epubURL:) → viewDidLoad → RDEPUBParser.parse → paginatePublication → readerView.reloadData → restoreLocation // MARK: - 阅读器主控制器 public final class RDEPUBReaderController: UIViewController { // MARK: - 公开属性 /// 阅读器委托,用于接收阅读状态变更通知 public weak var delegate: RDEPUBReaderDelegate? /// 阅读器配置,修改后自动判断是否需要重新分页或刷新内容 public var configuration: RDEPUBReaderConfiguration { didSet { readerContext.configuration = configuration persistReaderSettingsIfNeeded() guard isViewLoaded else { return } 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() } /// 当前页码(从 1 开始),nil 表示尚未加载 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 } /// 统一标注语义视图,对标 WXRead 的 WRBookmark 主模型。 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() } // MARK: - 私有属性 /// EPUB 文件 URL let epubURL: URL let persistence: RDEPUBReaderPersistence? let dependencies: RDEPUBReaderDependencies let readerView = RDReaderView() 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() 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 } } lazy var topToolView = runtime.makeTopToolView() lazy var bottomToolView = runtime.makeBottomToolView() lazy var searchBarView = RDEPUBReaderSearchBarView() /// 搜索栏是否当前可见 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 paginator: RDEPUBPaginator? { get { readerContext.paginator } set { readerContext.paginator = 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) // MARK: - 初始化 /// 使用 EPUB 文件 URL 初始化阅读器 /// 会自动从持久化存储中恢复用户的阅读偏好设置 /// - Parameters: /// - epubURL: EPUB 文件的本地 URL /// - configuration: 阅读器配置,默认使用 .default /// - persistence: 持久化策略,默认使用 UserDefaults 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) // Sync context after super.init readerContext.dependencies = dependencies readerContext.configuration = self.configuration readerContext.epubURL = epubURL readerContext.persistence = persistence self.currentBrightness = brightness } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// 使用已构建的 TextBook 初始化,跳过 EPUB 解析流程 /// 适用于 TXT 等纯文本文件,调用方需自行构建 TextBook /// - Parameters: /// - textBook: 已分页的 TextBook 实例 /// - bookIdentifier: 书籍唯一标识符,用于持久化 /// - title: 书籍标题 /// - textFileURL: 原始文本文件 URL,用于重建 TextBook /// - configuration: 阅读器配置 /// - persistence: 持久化策略 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) navigationController?.setNavigationBarHidden(true, animated: animated) navigationController?.interactivePopGestureRecognizer?.delegate = self navigationController?.interactivePopGestureRecognizer?.isEnabled = true } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationController?.setNavigationBarHidden(false, animated: animated) } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) startInitialLoadIfNeeded() } public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() runtime.viewportMonitor.viewDidLayoutSubviews() } public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) runtime.viewportMonitor.viewWillTransition(with: coordinator) } // MARK: - 搜索栏管理 /// 显示搜索栏。工具栏可见时立即添加到视图层级并滑入动画;否则仅标记状态, /// 等待 `handleToolViewVisibilityChanged` 在工具栏显示时再安装。 func showSearchBar() { guard !isSearchBarVisible else { return } isSearchBarVisible = true searchBarView.apply(theme: configuration.theme) searchBarView.onSearchSubmit = { [weak self] keyword in self?.runtime.search(keyword: keyword) self?.updateSearchCount() } searchBarView.onSearchPrevious = { [weak self] in _ = self?.runtime.searchPrevious() self?.updateSearchCount() } searchBarView.onSearchNext = { [weak self] in _ = self?.runtime.searchNext() self?.updateSearchCount() } searchBarView.onClose = { [weak self] in self?.hideSearchBar(clearSearch: true) } if readerView.isShowToolView { installSearchBarView() } } /// 将搜索栏添加到 readerView 并执行滑入动画 private func installSearchBarView() { guard searchBarView.superview == nil else { return } searchBarView.apply(theme: configuration.theme) readerView.addSubview(searchBarView) readerView.searchBarView = searchBarView let topToolbarHeight: CGFloat = readerView.safeAreaInsets.top + 52 NSLayoutConstraint.activate([ searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor), searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor), searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor, constant: topToolbarHeight), searchBarView.heightAnchor.constraint(equalToConstant: 52) ]) searchBarView.transform = CGAffineTransform(translationX: 0, y: -52) UIView.animate(withDuration: 0.3) { self.searchBarView.transform = .identity } if let keyword = searchState?.keyword, !keyword.isEmpty { searchBarView.restoreKeyword(keyword) updateSearchCount() } DispatchQueue.main.async { [weak self] in self?.searchBarView.textField.becomeFirstResponder() } } /// 隐藏搜索栏,带动画滑出 /// - Parameter clearSearch: 是否同时清除搜索状态 func hideSearchBar(clearSearch: Bool = false) { guard isSearchBarVisible else { return } isSearchBarVisible = false searchBarView.textField.resignFirstResponder() UIView.animate(withDuration: 0.3, animations: { self.searchBarView.transform = CGAffineTransform(translationX: 0, y: -52) }) { _ in self.searchBarView.removeFromSuperview() self.searchBarView.transform = .identity self.readerView.searchBarView = nil } if clearSearch { runtime.clearSearch() } } /// 同步搜索栏匹配计数 func updateSearchCount() { guard let searchState else { searchBarView.showNoResults() return } if let index = searchState.currentMatchIndex { searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count) } else if searchState.matches.isEmpty { searchBarView.showNoResults() } } /// 当工具栏可见性变化时同步搜索栏(由 RDReaderView 回调调用) func handleToolViewVisibilityChanged(isVisible: Bool) { if isVisible { if isSearchBarVisible { installSearchBarView() } else if searchState != nil { showSearchBar() } } else { if isSearchBarVisible { hideSearchBar(clearSearch: false) } } } }