修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 EPUB 资源、并发打开同一本书和多 UserDefaults 容器等场景下,存在旧异步结果覆盖新状态、锁屏控制串会话、大型明文资源被误拒绝、解压半成品被复用及标注跨容器混用的风险;同时高亮、书签、搜索和设置面板的空状态与自动化可访问性入口不完整。\n\n本次为朗读会话引入代次和当前 utterance 校验,远程控制改为仅响应当前会话并按自身 token 清理;加密资源 provider 先判定是否实际返回解密数据,明文大资源继续流式读取;解压缓存串行化以避免并发复用未完成目录。书签与高亮迁移至受保护文件,按 UserDefaults 容器隔离命名空间,保留标准容器旧文件兼容并确保新副本成功落盘后才删除历史数据。\n\n同步调整缓存淘汰、FoundationModels 弱链接、搜索/标注/设置面板交互与 UI 测试,并更新 Pod 生成配置及 API、风险文档。已执行 git diff --check 和 ReadViewDemo Debug Simulator 构建,结果为 BUILD SUCCEEDED。
This commit is contained in:
@@ -65,12 +65,18 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
|
||||
private let emptyStateLabel = UILabel()
|
||||
|
||||
private let navigationRowView = UIView()
|
||||
|
||||
private let previousButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
private let nextButton = RDEPUBReaderTintButton(type: .system)
|
||||
|
||||
private let countLabel = UILabel()
|
||||
|
||||
/// Collapsed to 0 in the pristine state so the prompt keeps its original
|
||||
/// spacing under the search field. See `updateNavigationRow(total:)`.
|
||||
private var navigationRowHeightConstraint: NSLayoutConstraint?
|
||||
|
||||
private var searchSections: [RDEPUBReaderSearchSection] = []
|
||||
|
||||
private var keyword = ""
|
||||
@@ -85,7 +91,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
setupSubviews()
|
||||
setupConstraints()
|
||||
setupActions()
|
||||
updateLegacyNavigationEnabled(false)
|
||||
updateNavigationRow(total: 0)
|
||||
showInitialState()
|
||||
}
|
||||
|
||||
@@ -94,20 +100,14 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
// Only claim touches that fall within the panelView or the
|
||||
// accessibility-only navigation buttons. Taps on the dimmed scrim
|
||||
// area pass through to the reader content below, allowing chrome
|
||||
// toggle taps to work.
|
||||
// Only claim touches that fall within the panelView. Taps on the dimmed
|
||||
// scrim area pass through to the reader content below, allowing chrome
|
||||
// toggle taps to work. The step controls live inside the panel, so they
|
||||
// no longer need a special case here.
|
||||
let panelPoint = convert(point, to: panelView)
|
||||
if panelView.point(inside: panelPoint, with: event) {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
// Also claim touches on the accessibility-only navigation buttons
|
||||
if previousButton.frame.contains(point)
|
||||
|| nextButton.frame.contains(point)
|
||||
|| countLabel.frame.contains(point) {
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -149,9 +149,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
|
||||
navigationRowView.backgroundColor = rowColor
|
||||
previousButton.tintColor = textColor
|
||||
nextButton.tintColor = textColor
|
||||
countLabel.textColor = textColor
|
||||
countLabel.textColor = secondaryTextColor
|
||||
countLabel.backgroundColor = .clear
|
||||
|
||||
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
||||
@@ -168,9 +169,11 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
}
|
||||
|
||||
func updateMatchCount(current: Int, total: Int) {
|
||||
// label 保持 "N/M" 原文:无障碍与 UI 测试都按这个格式读取,
|
||||
// 覆盖 accessibilityLabel 会让 XCUIElement.label 拿不到计数。
|
||||
countLabel.text = "\(current)/\(total)"
|
||||
textField.accessibilityValue = "\(current)/\(total)"
|
||||
updateLegacyNavigationEnabled(total > 0)
|
||||
updateNavigationRow(total: total)
|
||||
}
|
||||
|
||||
func showNoResults() {
|
||||
@@ -223,6 +226,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
private func setupSubviews() {
|
||||
backgroundButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
panelView.translatesAutoresizingMaskIntoConstraints = false
|
||||
navigationRowView.translatesAutoresizingMaskIntoConstraints = false
|
||||
grabberView.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchRowView.translatesAutoresizingMaskIntoConstraints = false
|
||||
searchFieldContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -241,6 +245,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
|
||||
panelView.addSubview(grabberView)
|
||||
panelView.addSubview(searchRowView)
|
||||
panelView.addSubview(navigationRowView)
|
||||
panelView.addSubview(tableView)
|
||||
panelView.addSubview(emptyStateLabel)
|
||||
|
||||
@@ -250,14 +255,15 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
searchFieldContainer.addSubview(searchIcon)
|
||||
searchFieldContainer.addSubview(textField)
|
||||
|
||||
addSubview(previousButton)
|
||||
addSubview(nextButton)
|
||||
addSubview(countLabel)
|
||||
navigationRowView.addSubview(countLabel)
|
||||
navigationRowView.addSubview(previousButton)
|
||||
navigationRowView.addSubview(nextButton)
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
searchIcon.image = UIImage(systemName: "magnifyingglass")
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up"), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
|
||||
let stepSymbol = UIImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
|
||||
previousButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: stepSymbol), for: .normal)
|
||||
nextButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: stepSymbol), for: .normal)
|
||||
} else {
|
||||
previousButton.setTitle("▲", for: .normal)
|
||||
nextButton.setTitle("▼", for: .normal)
|
||||
@@ -287,11 +293,16 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
nextButton.accessibilityIdentifier = "epub.reader.search.next"
|
||||
countLabel.accessibilityIdentifier = "epub.reader.search.count"
|
||||
textField.accessibilityIdentifier = "epub.reader.search.field"
|
||||
// 0.02 而非 0.01:CALayer.opacity 为 Float32,0.01 落盘后略小于
|
||||
// 0.01,会被 UIKit 命中测试按「透明视图」剔除,导致按钮永远点不中。
|
||||
previousButton.alpha = 0.02
|
||||
nextButton.alpha = 0.02
|
||||
countLabel.alpha = 0.02
|
||||
|
||||
previousButton.accessibilityLabel = "上一个匹配"
|
||||
nextButton.accessibilityLabel = "下一个匹配"
|
||||
|
||||
countLabel.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||
countLabel.textAlignment = .left
|
||||
countLabel.isAccessibilityElement = true
|
||||
|
||||
navigationRowView.layer.cornerRadius = 18
|
||||
navigationRowView.clipsToBounds = true
|
||||
|
||||
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
|
||||
tableView.dataSource = self
|
||||
@@ -345,33 +356,40 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
cancelButton.trailingAnchor.constraint(equalTo: searchRowView.trailingAnchor, constant: -18),
|
||||
cancelButton.centerYAnchor.constraint(equalTo: searchRowView.centerYAnchor),
|
||||
|
||||
navigationRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
||||
navigationRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
||||
navigationRowView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 12),
|
||||
|
||||
countLabel.leadingAnchor.constraint(equalTo: navigationRowView.leadingAnchor, constant: 16),
|
||||
countLabel.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
|
||||
|
||||
nextButton.trailingAnchor.constraint(equalTo: navigationRowView.trailingAnchor, constant: -8),
|
||||
nextButton.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
nextButton.topAnchor.constraint(equalTo: navigationRowView.topAnchor),
|
||||
nextButton.bottomAnchor.constraint(equalTo: navigationRowView.bottomAnchor),
|
||||
|
||||
previousButton.trailingAnchor.constraint(equalTo: nextButton.leadingAnchor, constant: -4),
|
||||
previousButton.centerYAnchor.constraint(equalTo: navigationRowView.centerYAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
previousButton.topAnchor.constraint(equalTo: navigationRowView.topAnchor),
|
||||
previousButton.bottomAnchor.constraint(equalTo: navigationRowView.bottomAnchor),
|
||||
|
||||
countLabel.trailingAnchor.constraint(lessThanOrEqualTo: previousButton.leadingAnchor, constant: -8),
|
||||
|
||||
tableView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 0),
|
||||
tableView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: 0),
|
||||
tableView.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 18),
|
||||
tableView.topAnchor.constraint(equalTo: navigationRowView.bottomAnchor, constant: 12),
|
||||
tableView.bottomAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.bottomAnchor),
|
||||
|
||||
emptyStateLabel.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 32),
|
||||
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
|
||||
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
|
||||
|
||||
// 无障碍/UI 测试专用的隐形控件:必须放在 panel 顶部而非视图
|
||||
// 左上角——视图左上角落在系统状态栏区域内,合成点击会被状态栏
|
||||
// 窗口拦截,永远到不了这些按钮。
|
||||
previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
|
||||
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
previousButton.widthAnchor.constraint(equalToConstant: 1),
|
||||
previousButton.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
|
||||
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
|
||||
nextButton.widthAnchor.constraint(equalToConstant: 1),
|
||||
nextButton.heightAnchor.constraint(equalToConstant: 1),
|
||||
|
||||
countLabel.topAnchor.constraint(equalTo: panelView.topAnchor),
|
||||
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
|
||||
countLabel.widthAnchor.constraint(equalToConstant: 1),
|
||||
countLabel.heightAnchor.constraint(equalToConstant: 1)
|
||||
emptyStateLabel.topAnchor.constraint(equalTo: navigationRowView.bottomAnchor, constant: 50)
|
||||
])
|
||||
|
||||
let heightConstraint = navigationRowView.heightAnchor.constraint(equalToConstant: 0)
|
||||
heightConstraint.isActive = true
|
||||
navigationRowHeightConstraint = heightConstraint
|
||||
}
|
||||
|
||||
private func setupActions() {
|
||||
@@ -384,9 +402,18 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
||||
backgroundButton.addTarget(self, action: #selector(closeAction), for: .touchUpInside)
|
||||
}
|
||||
|
||||
private func updateLegacyNavigationEnabled(_ enabled: Bool) {
|
||||
previousButton.isEnabled = enabled
|
||||
nextButton.isEnabled = enabled
|
||||
/// Stepping is only enabled once there are matches, but the row stays
|
||||
/// visible (showing `0/0` with disabled arrows) for any keyword the user has
|
||||
/// actually searched, so the result count is never silently missing. It
|
||||
/// collapses away only in the pristine "type a keyword" state.
|
||||
private func updateNavigationRow(total: Int) {
|
||||
let hasMatches = total > 0
|
||||
previousButton.isEnabled = hasMatches
|
||||
nextButton.isEnabled = hasMatches
|
||||
|
||||
let isVisible = hasMatches || !keyword.isEmpty
|
||||
navigationRowView.isHidden = !isVisible
|
||||
navigationRowHeightConstraint?.constant = isVisible ? 36 : 0
|
||||
}
|
||||
|
||||
private func showInitialState() {
|
||||
|
||||
Reference in New Issue
Block a user