修复阅读器资源、朗读与持久化稳定性
阅读器在多会话朗读、加密 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:
@@ -16,6 +16,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
|
||||
private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
|
||||
|
||||
/// 空状态提示。放在 tableView 的子视图而不是 `backgroundView`:
|
||||
/// backgroundView 不参与无障碍层级遍历,VoiceOver 与 UI 测试都读不到它。
|
||||
private let emptyStateLabel = UILabel()
|
||||
|
||||
private var filteredHighlights: [RDEPUBHighlight] {
|
||||
switch filterControl.selectedSegmentIndex {
|
||||
case 1:
|
||||
@@ -120,19 +124,28 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
guard filteredHighlights.isEmpty else {
|
||||
tableView.backgroundView = nil
|
||||
return
|
||||
}
|
||||
private func installEmptyStateLabelIfNeeded() {
|
||||
guard emptyStateLabel.superview == nil else { return }
|
||||
emptyStateLabel.textAlignment = .center
|
||||
emptyStateLabel.numberOfLines = 0
|
||||
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
emptyStateLabel.accessibilityIdentifier = "epub.reader.highlights.empty"
|
||||
emptyStateLabel.isAccessibilityElement = true
|
||||
tableView.addSubview(emptyStateLabel)
|
||||
// 固定在可视区域居中,不随内容滚动
|
||||
NSLayoutConstraint.activate([
|
||||
emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor),
|
||||
emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor),
|
||||
emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24),
|
||||
emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24)
|
||||
])
|
||||
}
|
||||
|
||||
let label = UILabel()
|
||||
label.text = emptyStateText()
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
label.accessibilityIdentifier = "epub.reader.highlights.empty"
|
||||
tableView.backgroundView = label
|
||||
private func updateEmptyState() {
|
||||
installEmptyStateLabelIfNeeded()
|
||||
emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
emptyStateLabel.text = emptyStateText()
|
||||
emptyStateLabel.isHidden = !filteredHighlights.isEmpty
|
||||
}
|
||||
|
||||
@objc private func filterChangedAction() {
|
||||
@@ -209,6 +222,9 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 同 highlights 面板:不能用 `tableView.backgroundView`,那样无障碍读不到。
|
||||
private let emptyStateLabel = UILabel()
|
||||
|
||||
init(bookmarks: [RDEPUBBookmark], theme: RDEPUBReaderTheme) {
|
||||
self.bookmarks = bookmarks.sorted { $0.createdAt > $1.createdAt }
|
||||
self.theme = theme
|
||||
@@ -279,18 +295,27 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||||
}
|
||||
|
||||
private func installEmptyStateLabelIfNeeded() {
|
||||
guard emptyStateLabel.superview == nil else { return }
|
||||
emptyStateLabel.text = "暂无书签"
|
||||
emptyStateLabel.textAlignment = .center
|
||||
emptyStateLabel.numberOfLines = 0
|
||||
emptyStateLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
emptyStateLabel.accessibilityIdentifier = "epub.reader.bookmarks.empty"
|
||||
emptyStateLabel.isAccessibilityElement = true
|
||||
tableView.addSubview(emptyStateLabel)
|
||||
NSLayoutConstraint.activate([
|
||||
emptyStateLabel.centerXAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerXAnchor),
|
||||
emptyStateLabel.centerYAnchor.constraint(equalTo: tableView.frameLayoutGuide.centerYAnchor),
|
||||
emptyStateLabel.leadingAnchor.constraint(greaterThanOrEqualTo: tableView.frameLayoutGuide.leadingAnchor, constant: 24),
|
||||
emptyStateLabel.trailingAnchor.constraint(lessThanOrEqualTo: tableView.frameLayoutGuide.trailingAnchor, constant: -24)
|
||||
])
|
||||
}
|
||||
|
||||
private func updateEmptyState() {
|
||||
guard bookmarks.isEmpty == false else {
|
||||
let label = UILabel()
|
||||
label.text = "暂无书签"
|
||||
label.textAlignment = .center
|
||||
label.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
label.numberOfLines = 0
|
||||
label.accessibilityIdentifier = "epub.reader.bookmarks.empty"
|
||||
tableView.backgroundView = label
|
||||
return
|
||||
}
|
||||
tableView.backgroundView = nil
|
||||
installEmptyStateLabelIfNeeded()
|
||||
emptyStateLabel.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
||||
emptyStateLabel.isHidden = !bookmarks.isEmpty
|
||||
}
|
||||
|
||||
private func titleText(for bookmark: RDEPUBBookmark) -> String {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
public protocol RDEPUBReaderPersistence: AnyObject {
|
||||
@@ -47,6 +48,113 @@ public extension RDEPUBReaderPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
/// 受文件保护的内容存储:高亮正文、批注、书签摘录属于书籍内容,不应明文
|
||||
/// 落在 UserDefaults(默认不加密、会进备份)。这里改写到 Application Support
|
||||
/// 下带 `NSFileProtection` 的文件,并排除 iCloud/iTunes 备份。
|
||||
public final class RDEPUBProtectedContentStore {
|
||||
|
||||
private let directoryURL: URL
|
||||
|
||||
private let protection: FileProtectionType
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private let namespace: String?
|
||||
|
||||
private let allowsLegacyFallback: Bool
|
||||
|
||||
/// 默认用 `.completeUntilFirstUserAuthentication`(首次解锁后可访问)而不是
|
||||
/// `.complete`:两者都在磁盘上加密,但 `.complete` 会让锁屏期间的保存直接
|
||||
/// 失败——阅读器在退到后台时可能正好赶上用户锁屏,那样会静默丢标注。
|
||||
/// 对安全要求更高的宿主可以显式传入 `.complete`。
|
||||
public init(
|
||||
directoryName: String = "ssreaderview-epub/protected",
|
||||
namespace: String? = nil,
|
||||
allowsLegacyFallback: Bool = true,
|
||||
protection: FileProtectionType = .completeUntilFirstUserAuthentication
|
||||
) {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
self.directoryURL = base.appendingPathComponent(directoryName, isDirectory: true)
|
||||
self.namespace = namespace
|
||||
self.allowsLegacyFallback = allowsLegacyFallback
|
||||
self.protection = protection
|
||||
}
|
||||
|
||||
/// bookIdentifier 可能含 `/`、空格等非法文件名字符,且长度不可控——十六进制
|
||||
/// 编码会翻倍,长书名很容易撞上 255 字节的文件名上限。用 SHA256 定长摘要。
|
||||
private func fileURL(forKey key: String) -> URL {
|
||||
let storageKey: String
|
||||
if let namespace {
|
||||
storageKey = namespace + "\u{0}" + key
|
||||
} else {
|
||||
storageKey = key
|
||||
}
|
||||
let digest = SHA256.hash(data: Data(storageKey.utf8))
|
||||
let name = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return directoryURL.appendingPathComponent("\(name).json", isDirectory: false)
|
||||
}
|
||||
|
||||
private func legacyFileURL(forKey key: String) -> URL {
|
||||
let digest = SHA256.hash(data: Data(key.utf8))
|
||||
let name = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return directoryURL.appendingPathComponent("\(name).json", isDirectory: false)
|
||||
}
|
||||
|
||||
private func ensureDirectory() throws {
|
||||
guard !fileManager.fileExists(atPath: directoryURL.path) else { return }
|
||||
try fileManager.createDirectory(
|
||||
at: directoryURL,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.protectionKey: protection]
|
||||
)
|
||||
var url = directoryURL
|
||||
var values = URLResourceValues()
|
||||
values.isExcludedFromBackup = true
|
||||
try? url.setResourceValues(values)
|
||||
}
|
||||
|
||||
public func read(forKey key: String) -> Data? {
|
||||
if let data = try? Data(contentsOf: fileURL(forKey: key)) {
|
||||
return data
|
||||
}
|
||||
guard namespace != nil, allowsLegacyFallback else { return nil }
|
||||
return try? Data(contentsOf: legacyFileURL(forKey: key))
|
||||
}
|
||||
|
||||
public func write(_ data: Data, forKey key: String) {
|
||||
_ = writeIfPossible(data, forKey: key)
|
||||
}
|
||||
|
||||
/// 返回写入是否完整成功。迁移旧数据时,只有成功落盘后才能删除原副本。
|
||||
@discardableResult
|
||||
func writeIfPossible(_ data: Data, forKey key: String) -> Bool {
|
||||
do {
|
||||
try ensureDirectory()
|
||||
try data.write(to: fileURL(forKey: key), options: .atomic)
|
||||
// atomic 写入会替换文件,保护属性需要写完再设一次
|
||||
try fileManager.setAttributes([.protectionKey: protection], ofItemAtPath: fileURL(forKey: key).path)
|
||||
return true
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBProtectedContentStore] ⚠️ Failed to write '\(key)': \(error)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public func removeValue(forKey key: String) {
|
||||
try? fileManager.removeItem(at: fileURL(forKey: key))
|
||||
if namespace != nil, allowsLegacyFallback {
|
||||
try? fileManager.removeItem(at: legacyFileURL(forKey: key))
|
||||
}
|
||||
}
|
||||
|
||||
public func removeAll() {
|
||||
try? fileManager.removeItem(at: directoryURL)
|
||||
}
|
||||
}
|
||||
|
||||
public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
|
||||
private let defaults: UserDefaults
|
||||
@@ -59,18 +167,80 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
|
||||
private let settingsKey: String
|
||||
|
||||
/// 高亮与书签(含书籍正文摘录)实际落盘的位置
|
||||
private let contentStore: RDEPUBProtectedContentStore
|
||||
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
locationPrefix: String = "ssreader.epub.location.",
|
||||
bookmarksPrefix: String = "ssreader.epub.bookmarks.",
|
||||
highlightsPrefix: String = "ssreader.epub.highlights.",
|
||||
settingsKey: String = "ssreader.epub.settings"
|
||||
settingsKey: String = "ssreader.epub.settings",
|
||||
contentStore: RDEPUBProtectedContentStore? = nil
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.locationPrefix = locationPrefix
|
||||
self.bookmarksPrefix = bookmarksPrefix
|
||||
self.highlightsPrefix = highlightsPrefix
|
||||
self.settingsKey = settingsKey
|
||||
if let contentStore {
|
||||
self.contentStore = contentStore
|
||||
} else {
|
||||
let namespaceKey = settingsKey + ".protectedContentNamespace"
|
||||
let isStandardDefaults = defaults === UserDefaults.standard
|
||||
let namespace: String
|
||||
if isStandardDefaults {
|
||||
namespace = "standard"
|
||||
} else if let storedNamespace = defaults.string(forKey: namespaceKey) {
|
||||
namespace = storedNamespace
|
||||
} else {
|
||||
namespace = UUID().uuidString
|
||||
defaults.set(namespace, forKey: namespaceKey)
|
||||
}
|
||||
self.contentStore = RDEPUBProtectedContentStore(
|
||||
namespace: namespace,
|
||||
// 旧版未分区文件无法区分来自哪个自定义 suite;只对标准容器保留迁移读取。
|
||||
allowsLegacyFallback: isStandardDefaults
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从受保护存储读取;首次访问时把历史明文数据迁移过去并删掉明文副本。
|
||||
private func loadContentData(key: String) -> Data? {
|
||||
if let data = contentStore.read(forKey: key) {
|
||||
return data
|
||||
}
|
||||
guard let legacy = defaults.data(forKey: key) else { return nil }
|
||||
if contentStore.writeIfPossible(legacy, forKey: key) {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
private func saveContentData(_ data: Data, key: String) {
|
||||
// 新副本确认落盘前保留历史数据,避免磁盘满或文件保护错误时丢失标注。
|
||||
if contentStore.writeIfPossible(data, forKey: key),
|
||||
defaults.object(forKey: key) != nil {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除某本书的阅读内容数据(高亮、书签、进度)。
|
||||
public func clearReadingData(for bookIdentifier: String) {
|
||||
contentStore.removeValue(forKey: highlightsPrefix + bookIdentifier)
|
||||
contentStore.removeValue(forKey: bookmarksPrefix + bookIdentifier)
|
||||
defaults.removeObject(forKey: highlightsPrefix + bookIdentifier)
|
||||
defaults.removeObject(forKey: bookmarksPrefix + bookIdentifier)
|
||||
defaults.removeObject(forKey: locationPrefix + bookIdentifier)
|
||||
}
|
||||
|
||||
/// 清除全部书籍的高亮与书签内容数据,包括尚未迁移的历史 UserDefaults 数据。
|
||||
public func clearAllReadingContent() {
|
||||
contentStore.removeAll()
|
||||
for key in defaults.dictionaryRepresentation().keys
|
||||
where key.hasPrefix(highlightsPrefix) || key.hasPrefix(bookmarksPrefix) {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
public func loadLocation(for bookIdentifier: String) -> RDEPUBLocation? {
|
||||
@@ -99,7 +269,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
}
|
||||
|
||||
public func loadBookmarks(for bookIdentifier: String) -> [RDEPUBBookmark] {
|
||||
guard let data = defaults.data(forKey: bookmarksPrefix + bookIdentifier) else {
|
||||
guard let data = loadContentData(key: bookmarksPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
do {
|
||||
@@ -115,7 +285,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
public func saveBookmarks(_ bookmarks: [RDEPUBBookmark], for bookIdentifier: String) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(bookmarks)
|
||||
defaults.set(data, forKey: bookmarksPrefix + bookIdentifier)
|
||||
saveContentData(data, key: bookmarksPrefix + bookIdentifier)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode bookmarks for '\(bookIdentifier)': \(error)")
|
||||
@@ -124,7 +294,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
}
|
||||
|
||||
public func loadHighlights(for bookIdentifier: String) -> [RDEPUBHighlight] {
|
||||
guard let data = defaults.data(forKey: highlightsPrefix + bookIdentifier) else {
|
||||
guard let data = loadContentData(key: highlightsPrefix + bookIdentifier) else {
|
||||
return []
|
||||
}
|
||||
do {
|
||||
@@ -145,7 +315,7 @@ public final class RDEPUBUserDefaultsPersistence: RDEPUBReaderPersistence {
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ saveHighlights data size (\(data.count) bytes) exceeds 1MB for: \(bookIdentifier)")
|
||||
#endif
|
||||
}
|
||||
defaults.set(data, forKey: highlightsPrefix + bookIdentifier)
|
||||
saveContentData(data, key: highlightsPrefix + bookIdentifier)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[RDEPUBUserDefaultsPersistence] ⚠️ Failed to encode highlights for '\(bookIdentifier)': \(error)")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+3
-4
@@ -198,8 +198,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
func presentHighlightsManager() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights else { return }
|
||||
guard !controller.activeHighlights.isEmpty else { return }
|
||||
|
||||
// 同 presentBookmarksManager:空列表照常呈现,面板有空状态提示。
|
||||
let highlightsController = RDEPUBReaderHighlightsViewController(
|
||||
highlights: controller.activeHighlights,
|
||||
theme: controller.configuration.theme,
|
||||
@@ -325,8 +324,8 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
|
||||
func presentBookmarksManager() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeBookmarks.isEmpty else { return }
|
||||
|
||||
// 空列表也要照常呈现:面板本身有「暂无书签」空状态,早退会让点击书签
|
||||
// 列表按钮什么反应都没有,用户无法区分「没有书签」和「功能坏了」。
|
||||
let bookmarksController = RDEPUBReaderBookmarksViewController(
|
||||
bookmarks: controller.activeBookmarks,
|
||||
theme: controller.configuration.theme
|
||||
|
||||
Reference in New Issue
Block a user