import UIKit final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate { private unowned let context: RDEPUBReaderContext private var settingsTransitionDelegate: RDEPUBReaderSettingsTransitionDelegate? init(context: RDEPUBReaderContext) { self.context = context } private var controller: RDEPUBReaderController? { context.controller } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { context.runtime?.settingsPanelDidDisappear() } func makeTopToolView() -> RDEPUBReaderTopToolViewProviding { let toolView: RDEPUBReaderTopToolViewProviding = context.controller?.dependencies.makeTopToolView?() ?? RDEPUBReaderTopToolView() toolView.onBack = { [weak self] in self?.handleBackAction() } toolView.onSearch = { [weak self] in self?.toggleSearchBar() } toolView.onToggleBookmark = { [weak self] in _ = self?.context.runtime?.toggleBookmark() } return toolView } func makeBottomToolView() -> RDEPUBReaderBottomToolViewProviding { let toolView: RDEPUBReaderBottomToolViewProviding = context.controller?.dependencies.makeBottomToolView?() ?? RDEPUBReaderBottomToolView() toolView.onShowTableOfContents = { [weak self] in self?.presentTableOfContents() } toolView.onShowBookmarks = { [weak self] in self?.context.runtime?.presentBookmarksManager() } toolView.onShowHighlights = { [weak self] in self?.context.runtime?.presentHighlightsManager() } toolView.onAddHighlight = { [weak self] in self?.context.runtime?.presentAnnotationCreation() } toolView.onShowSettings = { [weak self] in self?.presentSettings() } return toolView } func updateReaderChrome() { guard let controller else { return } let uiState = makeUIState() applyUIState(uiState) updateSearchBar() } func makeUIState() -> RDEPUBReaderUIState { guard let controller else { return .empty } return RDEPUBReaderUIState( canToggleBookmark: controller.currentBookIdentifier != nil, hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(), canShowBookmarks: !controller.activeBookmarks.isEmpty, canAddHighlight: controller.configuration.allowsHighlights && context.selectionState.hasSelection, canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty, showsTableOfContents: controller.configuration.showsTableOfContents, allowsHighlights: controller.configuration.allowsHighlights, showsSettingsPanel: controller.configuration.showsSettingsPanel ) } func applyUIState(_ state: RDEPUBReaderUIState) { guard let controller else { return } controller.topToolView.apply(theme: controller.configuration.theme) controller.topToolView.setTitle( controller.title ?? controller.parser?.metadata.title ?? controller.epubURL.deletingPathExtension().lastPathComponent ) controller.topToolView.setBookmarkEnabled(state.canToggleBookmark) controller.topToolView.setBookmarkSelected(state.hasBookmarkAtCurrentLocation) controller.bottomToolView.apply(theme: controller.configuration.theme) controller.bottomToolView.updateVisibility( showsTableOfContents: state.showsTableOfContents, allowsHighlights: state.allowsHighlights, showsSettingsPanel: state.showsSettingsPanel ) controller.bottomToolView.setBookmarksEnabled(state.canShowBookmarks) controller.bottomToolView.setAddHighlightEnabled(state.canAddHighlight) controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights) } private func hasBookmarkAtCurrentLocation() -> Bool { guard let controller else { return false } return context.runtime?.annotationCoordinator.currentBookmark() != nil } func presentSettings() { guard let controller else { return } guard controller.configuration.showsSettingsPanel else { return } context.runtime?.settingsPanelWillAppear() let settingsController = RDEPUBReaderSettingsViewController( configuration: controller.configuration, brightness: controller.currentBrightness ) settingsController.onBrightnessChange = { [weak controller] brightness in controller?.setScreenBrightness(brightness) } settingsController.onFontSizeChange = { [weak controller] fontSize in controller?.updateConfiguration { $0.fontSize = fontSize } } settingsController.onFontChoiceChange = { [weak controller] fontChoice in controller?.updateConfiguration { $0.fontChoice = fontChoice } } settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple } } settingsController.onDisplayTypeChange = { [weak controller] displayType in controller?.updateConfiguration { $0.displayType = displayType } } settingsController.onThemeChange = { [weak controller] theme in controller?.updateConfiguration { $0.theme = theme } } settingsController.onDismiss = { [weak self] in self?.context.runtime?.settingsPanelDidDisappear() } let navigationController = UINavigationController(rootViewController: settingsController) let transitionDelegate = RDEPUBReaderSettingsTransitionDelegate() settingsTransitionDelegate = transitionDelegate navigationController.setNavigationBarHidden(true, animated: false) navigationController.view.backgroundColor = .clear navigationController.view.isOpaque = false navigationController.modalPresentationStyle = .custom navigationController.transitioningDelegate = transitionDelegate controller.present(navigationController, animated: true) } func presentTableOfContents() { guard let controller else { return } guard controller.configuration.showsTableOfContents else { return } let items = controller.flattenedTableOfContentsItems( from: controller.publication?.tableOfContents ?? [], includePageNumbers: false ) guard !items.isEmpty else { return } let chapterController = RDEPUBReaderChapterListController( items: items, currentItem: controller.currentTableOfContentsItem, theme: controller.configuration.theme ) chapterController.onSelectItem = { [weak controller] item in guard let controller else { return } chapterController.dismiss(animated: true) { _ = controller.go(toTableOfContentsItem: item, animated: true) } } let navigationController = UINavigationController(rootViewController: chapterController) navigationController.modalPresentationStyle = .pageSheet controller.present(navigationController, animated: true) } func toggleSearchBar() { guard let controller else { return } if controller.isSearchBarVisible { controller.hideSearchBar() } else { controller.showSearchBar() } } func updateSearchBar() { guard let controller else { return } controller.searchBarView.apply(theme: controller.configuration.theme) if let searchState = controller.searchState { if let index = searchState.currentMatchIndex { controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count) } else if searchState.matches.isEmpty { controller.searchBarView.showNoResults() } } } func handleBackAction() { guard let controller else { return } close(controller) } private func close(_ controller: UIViewController) { let target = closestDismissTarget(from: controller) if let navigationController = target.navigationController, navigationController.viewControllers.first !== target { navigationController.popViewController(animated: true) return } if let navigationController = target.navigationController, navigationController.presentingViewController != nil { navigationController.dismiss(animated: true) return } if target.presentingViewController != nil { target.dismiss(animated: true) return } controller.dismiss(animated: true) } private func closestDismissTarget(from controller: UIViewController) -> UIViewController { var candidate: UIViewController = controller var current = controller.parent while let parent = current { if let navigationController = parent.navigationController, navigationController.viewControllers.contains(parent) { return parent } if parent.presentingViewController != nil || parent.navigationController?.presentingViewController != nil { return parent } candidate = parent current = parent.parent } return candidate } } private final class RDEPUBReaderSettingsTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate { func presentationController( forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController ) -> UIPresentationController? { RDEPUBReaderSettingsPresentationController( presentedViewController: presented, presenting: presenting ?? source ) } } private final class RDEPUBReaderSettingsPresentationController: UIPresentationController { private let dimmingView = UIControl() override var frameOfPresentedViewInContainerView: CGRect { guard let containerView else { return .zero } let bounds = containerView.bounds let isLandscape = bounds.width > bounds.height if isLandscape { let width = min(390, max(300, bounds.width * 0.32)) let height = min(340, bounds.height - 32) return CGRect( x: bounds.maxX - width - 16, y: bounds.midY - height / 2, width: width, height: height ) } let height = min(350, max(280, bounds.height * 0.40)) return CGRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height) } override func presentationTransitionWillBegin() { guard let containerView else { return } dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.18) dimmingView.alpha = 0 dimmingView.addTarget(self, action: #selector(dismissPresentedController), for: .touchUpInside) dimmingView.frame = containerView.bounds containerView.insertSubview(dimmingView, at: 0) presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in self.dimmingView.alpha = 1 }) } override func dismissalTransitionWillBegin() { presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in self.dimmingView.alpha = 0 }) } override func containerViewWillLayoutSubviews() { super.containerViewWillLayoutSubviews() dimmingView.frame = containerView?.bounds ?? .zero presentedView?.frame = frameOfPresentedViewInContainerView } override func dismissalTransitionDidEnd(_ completed: Bool) { super.dismissalTransitionDidEnd(completed) if completed { dimmingView.removeFromSuperview() } } @objc private func dismissPresentedController() { if let navigationController = presentedViewController as? UINavigationController, let settingsController = navigationController.topViewController as? RDEPUBReaderSettingsViewController { settingsController.notifyDismissalIfNeeded() } presentedViewController.dismiss(animated: true) } }