feat: unify reader chrome state management

- Add RDEPUBReaderUIState struct for centralized UI state model
- Refactor RDEPUBReaderChromeCoordinator with makeUIState() and applyUIState()
- Remove direct UI updates from RDEPUBReaderAnnotationCoordinator
- Consolidate updateBookmarkChrome() into updateReaderChrome()
- Update RDEPUBReaderContext, Runtime, and LocationCoordinator
- Add reader problem fix development checklist documentation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-12 17:51:08 +08:00
co-authored by Claude Opus 4.7
parent d15187b730
commit ed390da147
12 changed files with 908 additions and 47 deletions
@@ -31,7 +31,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
totalPages: pageCountOfReaderView(readerView: readerView),
configuration: configuration,
highlights: textHighlights(for: resolvedPage.page),
searchState: searchState
searchState: searchState(for: resolvedPage.page)
)
return contentView
}
@@ -46,7 +46,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
totalPages: textBook.pages.count,
configuration: configuration,
highlights: textHighlights(for: page),
searchState: searchState
searchState: searchState(for: page)
)
return contentView
}
@@ -91,6 +91,57 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
}
}
///
/// currentMatchIndex
/// href
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
guard let globalSearchState = searchState else { return nil }
let matches = globalSearchState.matches.filter { searchMatch in
searchMatchBelongsToPage(searchMatch, page: page)
}
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
return globalSearchState.matches.isEmpty ? globalSearchState : nil
}
let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
matches.firstIndex(of: currentMatch)
}
return RDEPUBSearchState(
keyword: globalSearchState.keyword,
matches: matches,
currentMatchIndex: currentMatchIndex
)
}
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
if let textBook,
let chapterData = textBook.chapterData(for: page.href),
let range = chapterData.absoluteRange(for: searchMatch) {
return NSIntersectionRange(range, page.contentRange).length > 0
}
let pageHref = normalizedPageHref(for: page)
let matchHref = normalizedSearchHref(searchMatch.href)
guard pageHref == matchHref else { return false }
guard let rangeLocation = searchMatch.rangeLocation else {
return false
}
let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength)
return NSIntersectionRange(range, page.contentRange).length > 0
}
private func normalizedPageHref(for page: RDEPUBTextPage) -> String {
guard let publication else { return page.href }
return publication.resourceResolver.normalizedHref(page.href) ?? page.href
}
private func normalizedSearchHref(_ href: String) -> String {
guard let publication else { return href }
return publication.resourceResolver.normalizedHref(href) ?? href
}
/// href
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
readerContext.textChapterData(forNormalizedHref: href)
@@ -138,11 +138,6 @@ extension RDEPUBReaderController {
runtime.updateReaderChrome()
}
///
func updateBookmarkChrome() {
runtime.updateBookmarkChrome()
}
///
func presentBookmarksManager() {
runtime.presentBookmarksManager()
@@ -38,9 +38,7 @@ final class RDEPUBReaderAnnotationCoordinator {
controller.readerView.isShowToolView == false {
controller.readerView.tapCenter()
}
controller.bottomToolView.setAddHighlightEnabled(
controller.configuration.allowsHighlights && controller.currentSelection != nil
)
controller.updateReaderChrome()
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
}
@@ -321,13 +319,6 @@ final class RDEPUBReaderAnnotationCoordinator {
return controller.restoreReadingLocation(bookmark.location, animated: animated)
}
/// UI
func updateBookmarkChrome() {
guard let controller else { return }
controller.topToolView.setBookmarkSelected(currentBookmark() != nil)
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
}
///
func presentBookmarksManager() {
guard let controller else { return }
@@ -468,10 +459,10 @@ final class RDEPUBReaderAnnotationCoordinator {
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
updateBookmarkChrome()
controller.updateReaderChrome()
}
private func currentBookmark() -> RDEPUBBookmark? {
func currentBookmark() -> RDEPUBBookmark? {
guard let controller else { return nil }
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
return nil
@@ -57,6 +57,29 @@ final class RDEPUBReaderChromeCoordinator {
///
func updateReaderChrome() {
guard let controller else { return }
let uiState = makeUIState()
applyUIState(uiState)
updateSearchBar()
}
/// UI
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 && controller.currentSelection != nil,
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
showsTableOfContents: controller.configuration.showsTableOfContents,
allowsHighlights: controller.configuration.allowsHighlights,
showsSettingsPanel: controller.configuration.showsSettingsPanel
)
}
/// UI
func applyUIState(_ state: RDEPUBReaderUIState) {
guard let controller else { return }
controller.topToolView.apply(theme: controller.configuration.theme)
controller.topToolView.setTitle(
@@ -64,22 +87,23 @@ final class RDEPUBReaderChromeCoordinator {
?? controller.parser?.metadata.title
?? controller.epubURL.deletingPathExtension().lastPathComponent
)
controller.topToolView.setBookmarkEnabled(controller.currentBookIdentifier != nil)
controller.topToolView.setBookmarkEnabled(state.canToggleBookmark)
controller.topToolView.setBookmarkSelected(state.hasBookmarkAtCurrentLocation)
controller.bottomToolView.apply(theme: controller.configuration.theme)
controller.bottomToolView.updateVisibility(
showsTableOfContents: controller.configuration.showsTableOfContents,
allowsHighlights: controller.configuration.allowsHighlights,
showsSettingsPanel: controller.configuration.showsSettingsPanel
showsTableOfContents: state.showsTableOfContents,
allowsHighlights: state.allowsHighlights,
showsSettingsPanel: state.showsSettingsPanel
)
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
controller.bottomToolView.setAddHighlightEnabled(
controller.configuration.allowsHighlights && controller.currentSelection != nil
)
controller.bottomToolView.setHighlightsEnabled(
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
)
controller.updateBookmarkChrome()
updateSearchBar()
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
}
///
@@ -369,9 +369,4 @@ final class RDEPUBReaderContext {
func applyReaderViewConfiguration() {
controller?.applyReaderViewConfiguration()
}
/// UI
func updateBookmarkChrome() {
controller?.updateBookmarkChrome()
}
}
@@ -87,6 +87,6 @@ final class RDEPUBReaderLocationCoordinator {
controller.persistence?.saveLocation(location, for: currentBookIdentifier)
controller.delegate?.epubReader(controller, didUpdateLocation: location)
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
controller.updateBookmarkChrome()
controller.updateReaderChrome()
}
}
@@ -193,10 +193,6 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.go(toBookmarkID: id, animated: animated)
}
func updateBookmarkChrome() {
annotationCoordinator.updateBookmarkChrome()
}
func presentBookmarksManager() {
annotationCoordinator.presentBookmarksManager()
}
@@ -0,0 +1,38 @@
import Foundation
/// UI /
///
/// ChromeCoordinator AnnotationCoordinator
///
struct RDEPUBReaderUIState {
///
let canToggleBookmark: Bool
///
let hasBookmarkAtCurrentLocation: Bool
///
let canShowBookmarks: Bool
///
let canAddHighlight: Bool
///
let canShowHighlights: Bool
///
let showsTableOfContents: Bool
///
let allowsHighlights: Bool
///
let showsSettingsPanel: Bool
}
extension RDEPUBReaderUIState {
///
static let empty = RDEPUBReaderUIState(
canToggleBookmark: false,
hasBookmarkAtCurrentLocation: false,
canShowBookmarks: false,
canAddHighlight: false,
canShowHighlights: false,
showsTableOfContents: true,
allowsHighlights: true,
showsSettingsPanel: true
)
}
@@ -64,7 +64,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
let pageStart = pageRange.lowerBound
let pageEndExclusive = pageRange.upperBound
for match in searchState.matches where match.href == page.href {
for match in searchState.matches {
guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength
let overlapStart = max(matchStart, pageStart)
@@ -100,7 +100,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
for match in searchState.matches where match.href == page.href {
for match in searchState.matches {
guard let matchStart = match.rangeLocation else { continue }
let matchEnd = matchStart + match.rangeLength
let overlapStart = max(matchStart, pageStart)
@@ -40,6 +40,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
let view = RDEPUBTextPageRenderView()
view.backgroundColor = .clear
view.isOpaque = false
view.isAccessibilityElement = true
view.accessibilityTraits = .staticText
return view
}()
@@ -118,6 +120,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
} else {
self.hideSelectionMenu()
}
self.updateAccessibilityDecorationSummary()
self.delegate?.textContentView(self, didChangeSelection: selection)
}
selectionController.pageProvider = { [weak self] in self?.currentPage }
@@ -250,6 +253,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
#endif
updateAccessibilityDecorationSummary()
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
@@ -262,6 +266,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateAccessibilityDecorationSummary()
UIMenuController.shared.setMenuVisible(false, animated: true)
}
@@ -570,13 +575,21 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
#endif
}
private func updateAccessibilityDecorationSummary() {
#if canImport(DTCoreText)
coreTextContentView.accessibilityValue = [
backgroundOverlayView.decorationSummary(),
overlayView.decorationSummary()
].joined(separator: " | ")
#endif
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil }
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
guard let absoluteRange else { return nil }
let matches = currentHighlights.filter { highlight in
guard highlight.location.href == page.href,
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
return false
}
return NSIntersectionRange(range, absoluteRange).length > 0